Fazekit

Code

"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Eye, EyeOff, MousePointer2 } from "lucide-react";
import { cn } from "@/lib/utils";

export interface MagneticGridRevealProps {
  /** Content hidden under the tiles. */
  children?: React.ReactNode;
  /** Target tile size in px; the grid adapts to the container. */
  tileSize?: number;
  /** Flip radius around the cursor, in tiles. */
  radius?: number;
  /** ms a tile stays flipped after the cursor leaves. */
  linger?: number;
  height?: number;
  coverLabel?: string;
  defaultRevealed?: boolean;
  onRevealChange?: (revealed: boolean) => void;
  className?: string;
}

interface TileState {
  flipped: boolean;
  axis: "x" | "y";
  dir: 1 | -1;
  tilt: [number, number];
}

export function MagneticGridReveal({
  children,
  tileSize = 58,
  radius = 1.7,
  linger = 650,
  height = 420,
  coverLabel = "Hover to peek · click to reveal",
  defaultRevealed = false,
  onRevealChange,
  className,
}: MagneticGridRevealProps) {
  const reduce = useReducedMotion() ?? false;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const tileRefs = React.useRef<(HTMLDivElement | null)[]>([]);
  const [dims, setDims] = React.useState({ cols: 12, rows: 7 });
  const [revealed, setRevealed] = React.useState(defaultRevealed);
  const [peeking, setPeeking] = React.useState(false);
  const pointer = React.useRef<{ c: number; r: number } | null>(null);
  const lastHit = React.useRef<number[]>([]);
  const tiles = React.useRef<TileState[]>([]);
  const raf = React.useRef(0);
  const revealedRef = React.useRef(revealed);
  const { cols, rows } = dims;
  const count = cols * rows;

  React.useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([e]) => {
      const c = Math.max(4, Math.round(e.contentRect.width / tileSize));
      const r = Math.max(3, Math.round(e.contentRect.height / tileSize));
      setDims((d) => (d.cols === c && d.rows === r ? d : { cols: c, rows: r }));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [tileSize]);

  const apply = React.useCallback(
    (i: number, s: TileState, delay = 0) => {
      const el = tileRefs.current[i];
      if (!el) return;
      el.style.transitionDelay = `${delay}ms`;
      const flip = s.flipped ? 180 * s.dir : 0;
      const rx = s.axis === "x" ? flip : 0;
      const ry = s.axis === "y" ? flip : 0;
      el.style.transform = `rotateX(${rx + (s.flipped ? 0 : s.tilt[0])}deg) rotateY(${ry + (s.flipped ? 0 : s.tilt[1])}deg)`;
    },
    [],
  );

  // Reset tile state when the grid changes.
  React.useEffect(() => {
    tiles.current = Array.from({ length: count }, () => ({ flipped: revealedRef.current, axis: "y", dir: 1, tilt: [0, 0] }));
    lastHit.current = Array.from({ length: count }, () => -Infinity);
    tiles.current.forEach((s, i) => apply(i, s));
  }, [count, apply]);

  const tickRef = React.useRef<() => void>(() => {});
  const tick = React.useCallback(() => {
    const now = performance.now();
    const p = pointer.current;
    let busy = !!p;
    for (let i = 0; i < count; i++) {
      const s = tiles.current[i];
      if (!s) continue;
      const c = i % cols;
      const r = Math.floor(i / cols);
      let tilt: [number, number] = [0, 0];
      if (p) {
        const dx = c + 0.5 - p.c;
        const dy = r + 0.5 - p.r;
        const d = Math.hypot(dx, dy);
        if (d < radius) {
          if (!s.flipped) {
            s.axis = Math.abs(dx) > Math.abs(dy) ? "y" : "x";
            s.dir = (s.axis === "y" ? dx : -dy) >= 0 ? 1 : -1;
          }
          lastHit.current[i] = now;
        } else if (d < radius * 2.4 && !reduce) {
          const f = (1 - d / (radius * 2.4)) * 22;
          tilt = [(-dy / d) * f, (dx / d) * f];
        }
      }
      const flipped = now - lastHit.current[i] < linger;
      if (flipped) busy = true;
      const changed = flipped !== s.flipped || Math.abs(tilt[0] - s.tilt[0]) > 0.5 || Math.abs(tilt[1] - s.tilt[1]) > 0.5;
      if (changed) {
        s.flipped = flipped;
        s.tilt = tilt;
        apply(i, s);
      }
    }
    if (busy) raf.current = requestAnimationFrame(() => tickRef.current());
    else {
      raf.current = 0;
      setPeeking(false);
    }
  }, [count, cols, radius, linger, reduce, apply]);

  React.useLayoutEffect(() => {
    tickRef.current = tick;
  }, [tick]);
  React.useEffect(() => () => cancelAnimationFrame(raf.current), []);

  const onMove = (e: React.PointerEvent) => {
    if (revealedRef.current) return;
    const r = e.currentTarget.getBoundingClientRect();
    pointer.current = { c: ((e.clientX - r.left) / r.width) * cols, r: ((e.clientY - r.top) / r.height) * rows };
    setPeeking(true);
    if (!raf.current) raf.current = requestAnimationFrame(() => tickRef.current());
  };

  const ripple = (origin?: { c: number; r: number }) => {
    const next = !revealedRef.current;
    revealedRef.current = next;
    setRevealed(next);
    onRevealChange?.(next);
    pointer.current = null;
    cancelAnimationFrame(raf.current);
    raf.current = 0;
    const o = origin ?? { c: cols / 2, r: rows / 2 };
    for (let i = 0; i < count; i++) {
      const s = tiles.current[i];
      if (!s) continue;
      const c = i % cols;
      const r = Math.floor(i / cols);
      const dx = c + 0.5 - o.c;
      const dy = r + 0.5 - o.r;
      s.axis = Math.abs(dx) > Math.abs(dy) ? "y" : "x";
      s.dir = (s.axis === "y" ? dx : -dy) >= 0 ? 1 : -1;
      s.flipped = next;
      s.tilt = [0, 0];
      lastHit.current[i] = -Infinity;
      apply(i, s, reduce ? 0 : Math.hypot(dx, dy) * 45);
    }
    // Clear the stagger so hover stays snappy afterwards.
    window.setTimeout(() => tileRefs.current.forEach((el) => el && (el.style.transitionDelay = "0ms")), 1400);
  };

  return (
    <div
      ref={rootRef}
      className={cn("relative w-full overflow-hidden rounded-3xl border bg-background", className)}
      style={{ height }}
    >
      <div className="absolute inset-0" inert={!revealed}>
        {children ?? <DefaultContent />}
      </div>

      <div
        aria-hidden
        className={cn("absolute inset-0 grid [perspective:900px]", revealed ? "pointer-events-none" : "cursor-pointer")}
        style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))` }}
        onPointerMove={onMove}
        onPointerLeave={() => (pointer.current = null)}
        onClick={(e) => {
          const r = e.currentTarget.getBoundingClientRect();
          ripple({ c: ((e.clientX - r.left) / r.width) * cols, r: ((e.clientY - r.top) / r.height) * rows });
        }}
      >
        {Array.from({ length: count }, (_, i) => {
          const c = i % cols;
          const r = Math.floor(i / cols);
          return (
            <div
              key={`${cols}-${i}`}
              ref={(el) => {
                tileRefs.current[i] = el;
              }}
              className="relative [transform-style:preserve-3d] will-change-transform"
              style={{ transition: reduce ? "transform 0.2s" : "transform 0.65s cubic-bezier(0.2, 0.9, 0.25, 1.15)" }}
            >
              <div
                className="absolute -inset-[0.5px] border-[0.5px] border-foreground/[0.06] bg-muted [backface-visibility:hidden] dark:border-white/[0.05]"
                style={{
                  backgroundImage:
                    "radial-gradient(120% 90% at 20% 10%, color-mix(in oklch, var(--color-primary) 22%, transparent), transparent 60%), radial-gradient(90% 90% at 90% 100%, color-mix(in oklch, #ec4899 16%, transparent), transparent 60%)",
                  backgroundSize: `${cols * 100}% ${rows * 100}%`,
                  backgroundPosition: `${cols > 1 ? (c / (cols - 1)) * 100 : 0}% ${rows > 1 ? (r / (rows - 1)) * 100 : 0}%`,
                }}
              >
                <span className="absolute top-1/2 left-1/2 size-1 -translate-1/2 rounded-full bg-foreground/15" />
              </div>
            </div>
          );
        })}
      </div>

      {/* Hint */}
      <div
        aria-hidden
        className={cn(
          "pointer-events-none absolute inset-0 grid place-items-center transition-opacity duration-500",
          revealed || peeking ? "opacity-0" : "opacity-100",
        )}
      >
        <span className="inline-flex items-center gap-2 rounded-full border bg-background/80 px-4 py-2 text-sm font-medium shadow-lg backdrop-blur">
          <MousePointer2 className="size-4 text-primary" /> {coverLabel}
        </span>
      </div>

      <button
        type="button"
        onClick={() => ripple()}
        aria-pressed={revealed}
        className="absolute top-3 right-3 z-10 inline-flex h-8 items-center gap-1.5 rounded-full border bg-background/85 px-3 text-xs font-medium shadow-sm backdrop-blur transition hover:bg-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
      >
        {revealed ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
        {revealed ? "Cover" : "Reveal"}
      </button>
    </div>
  );
}

function DefaultContent() {
  return (
    <div className="relative flex size-full flex-col items-center justify-center overflow-hidden bg-[radial-gradient(90%_70%_at_50%_0%,#4f46e5_0%,#1e1b4b_55%,#0b0a1a_100%)] px-6 text-center text-white">
      <div aria-hidden className="absolute inset-0 bg-[linear-gradient(transparent_95%,rgba(255,255,255,0.06)_95%),linear-gradient(90deg,transparent_95%,rgba(255,255,255,0.06)_95%)] bg-[size:28px_28px]" />
      <span className="relative rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-semibold tracking-[0.2em] uppercase">Now live</span>
      <h3 className="relative mt-4 text-3xl font-semibold tracking-tight sm:text-5xl">Northwind 3.0</h3>
      <p className="relative mt-3 max-w-md text-sm text-white/75 sm:text-base">Realtime sync, offline-first storage and a brand new plugin runtime.</p>
      <dl className="relative mt-6 grid grid-cols-3 gap-6 text-left sm:gap-10">
        {[
          ["4.2×", "faster sync"],
          ["120", "new plugins"],
          ["0 ms", "cold starts"],
        ].map(([v, l]) => (
          <div key={l}>
            <dt className="sr-only">{l}</dt>
            <dd className="text-xl font-semibold sm:text-2xl">{v}</dd>
            <dd className="text-xs text-white/60">{l}</dd>
          </div>
        ))}
      </dl>
      <button className="relative mt-7 rounded-full bg-white px-5 py-2.5 text-sm font-medium text-indigo-950 transition hover:bg-white/90 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-950 focus-visible:outline-none">
        Read the release notes
      </button>
    </div>
  );
}

More in Motion & Scroll

View all →