Workspace / component / animations

Antigravity

Child elements float free of the layout on independent orbits, drifting like objects in zero g. Physics is deterministic, amplitude is a prop.

Workspacecomponentanimations

paxfx add blocks/component-animations-antigravitylocal only

Tier: high. Reduced motion: Children hold their layout positions.

"use client";

import { Children, type CSSProperties, type ReactNode } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";

export interface AntigravityProps {
  /** Multiplier on drift distance; 1 is the tuned default. */
  amplitude?: number;
  /** Base seconds for one orbit; each child gets a deterministic offset. */
  period?: number;
  className?: string;
  children?: ReactNode;
}

const DEFAULT_ITEMS: ReactNode[] = [
  <span
    key="chip-motion"
    className="rounded-full border border-[var(--pax-border,rgba(18,17,15,0.12))] bg-[var(--pax-paper,#f6f2ea)] px-4 py-2 text-sm font-semibold text-[var(--pax-ink,#12110f)]"
  >
    Motion
  </span>,
  <span
    key="dot-acid"
    aria-hidden="true"
    className="block h-10 w-10 rounded-full bg-[var(--pax-acid,#c8ff00)]"
  />,
  <span
    key="chip-canvas"
    className="rounded-full bg-[var(--pax-acid,#c8ff00)] px-4 py-2 text-sm font-semibold text-[var(--pax-ink,#12110f)]"
  >
    Canvas
  </span>,
  <span
    key="square-paper"
    aria-hidden="true"
    className="block h-8 w-8 rotate-12 rounded-lg border border-[var(--pax-paper,#f6f2ea)] opacity-60"
  />,
  <span
    key="chip-layout"
    className="rounded-full border border-[var(--pax-paper,#f6f2ea)] px-4 py-2 text-sm font-semibold text-[var(--pax-paper,#f6f2ea)]"
  >
    Layout
  </span>,
];

/**
 * Children float free of the layout on independent slow orbits. Each child
 * gets a deterministic duration, phase, and drift path from its index, so
 * the field never syncs up and captures are reproducible. Motion is CSS
 * keyframes on transform only; reduced motion holds every child in place.
 */
export function Antigravity({ amplitude = 1, period = 9, className, children }: AntigravityProps) {
  const reduced = useReducedMotion();
  const [ref, inView] = useInView<HTMLDivElement>({ threshold: 0.2 });
  const animate = inView && !reduced;

  const items =
    children !== undefined && children !== null ? Children.toArray(children) : DEFAULT_ITEMS;
  const amp = Math.max(0, amplitude);

  const orbitStyle = (i: number): CSSProperties | undefined => {
    if (!animate) return undefined;
    const duration = period * (1 + ((i * 37) % 40) / 100);
    const delay = -duration * (((i * 61) % 100) / 100);
    return {
      animation: `pax-antigravity-orbit-${(i % 3) + 1} ${duration.toFixed(2)}s ease-in-out ${delay.toFixed(2)}s infinite`,
    };
  };

  return (
    <div
      ref={ref}
      className={cn(
        "relative isolate min-h-[240px] overflow-hidden rounded-2xl border border-[var(--pax-border,rgba(18,17,15,0.12))]",
        "bg-[var(--pax-night,#0b0d10)] text-[var(--pax-paper,#f6f2ea)]",
        className,
      )}
      data-pax-asset="component-animations-antigravity"
      style={{ "--pax-anti-amp": amp } as CSSProperties}
    >
      <style>{`
        @keyframes pax-antigravity-orbit-1 {
          0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
          25% { transform: translate3d(calc(var(--pax-anti-amp, 1) * 10px), calc(var(--pax-anti-amp, 1) * -14px), 0) rotate(2deg); }
          50% { transform: translate3d(calc(var(--pax-anti-amp, 1) * -6px), calc(var(--pax-anti-amp, 1) * -22px), 0) rotate(-1.5deg); }
          75% { transform: translate3d(calc(var(--pax-anti-amp, 1) * -12px), calc(var(--pax-anti-amp, 1) * -8px), 0) rotate(1deg); }
        }
        @keyframes pax-antigravity-orbit-2 {
          0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
          25% { transform: translate3d(calc(var(--pax-anti-amp, 1) * -14px), calc(var(--pax-anti-amp, 1) * 8px), 0) rotate(-2deg); }
          50% { transform: translate3d(calc(var(--pax-anti-amp, 1) * -4px), calc(var(--pax-anti-amp, 1) * 18px), 0) rotate(2.5deg); }
          75% { transform: translate3d(calc(var(--pax-anti-amp, 1) * 10px), calc(var(--pax-anti-amp, 1) * 6px), 0) rotate(-1deg); }
        }
        @keyframes pax-antigravity-orbit-3 {
          0%, 100% { transform: translate3d(0, 0, 0) rotate(0deg); }
          25% { transform: translate3d(calc(var(--pax-anti-amp, 1) * 8px), calc(var(--pax-anti-amp, 1) * 12px), 0) rotate(1.5deg); }
          50% { transform: translate3d(calc(var(--pax-anti-amp, 1) * 18px), calc(var(--pax-anti-amp, 1) * -4px), 0) rotate(-2deg); }
          75% { transform: translate3d(calc(var(--pax-anti-amp, 1) * 4px), calc(var(--pax-anti-amp, 1) * -12px), 0) rotate(1deg); }
        }
      `}</style>
      <div className="flex min-h-[240px] flex-wrap items-center justify-center gap-8 p-10">
        {items.map((item, i) => (
          <div key={i} className="will-change-transform" style={orbitStyle(i)}>
            {item}
          </div>
        ))}
      </div>
    </div>
  );
}