Skip to content

Spotlight Card Grid

A bento of dark cards lit by one shared light: it crosses the gaps, glows on card borders, brightens each card’s illustration and drifts on its own until you take over.

DOM + CSS0 deps1 file · 13 KB TSXreduced-motion aware

Freeglassminimal
Preview

Props

220
2
0.16
Idle drift
No runtime dependencies
"use client";

import { useEffect, useRef } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";

export type SpotlightItem = {
  title: string;
  body: string;
  /** Small illustration. Draw it with currentColor for the accent; it brightens as the light reaches the card. */
  icon?: ReactNode;
};

const BASE = "rgba(255,255,255,0.5)";
const FAINT = "rgba(255,255,255,0.22)";

const svgProps = {
  viewBox: "0 0 160 44",
  fill: "none",
  strokeWidth: 1.5,
  strokeLinecap: "round" as const,
  strokeLinejoin: "round" as const,
  "aria-hidden": true,
  className: "block h-11 w-auto max-w-full",
};

const DEFAULT_ITEMS: SpotlightItem[] = [
  {
    title: "Realtime sync",
    body: "Every edit lands on every device in under a frame of latency, with conflicts merged for you.",
    icon: (
      <svg {...svgProps}>
        <path d="M2 30c14 0 18-20 34-18s20 22 38 14 26-22 44-16 30 8 42 2" stroke={BASE} />
        <path d="M2 36c16 0 22-10 38-8s24 10 40 4 30-16 44-12 24 6 34 4" stroke={FAINT} strokeDasharray="2 5" />
        <circle cx="36" cy="12" r="2.5" fill="#0b0c0f" stroke={BASE} />
        <circle cx="118" cy="10" r="2.5" fill="#0b0c0f" stroke={BASE} />
        <circle cx="74" cy="26" r="6" stroke="currentColor" opacity="0.4" />
        <circle cx="74" cy="26" r="2.5" fill="currentColor" stroke="none" />
      </svg>
    ),
  },
  {
    title: "Branching",
    body: "Try a change in isolation, then merge it back.",
    icon: (
      <svg {...svgProps}>
        <path d="M18 40V6" stroke={BASE} />
        <path d="M18 30c0-10 8-14 22-14h40" stroke={BASE} />
        <path d="M80 16c10 0 14 6 14 14" stroke={FAINT} strokeDasharray="2 5" />
        <circle cx="18" cy="40" r="2.5" fill="#0b0c0f" stroke={BASE} />
        <circle cx="18" cy="6" r="2.5" fill="#0b0c0f" stroke={BASE} />
        <circle cx="80" cy="16" r="4.5" fill="currentColor" stroke="none" />
      </svg>
    ),
  },
  {
    title: "Scoped access",
    body: "Keys that can only reach what you hand them.",
    icon: (
      <svg {...svgProps}>
        <circle cx="40" cy="22" r="19" stroke={FAINT} />
        <circle cx="40" cy="22" r="12.5" stroke={BASE} />
        <circle cx="40" cy="22" r="6" stroke={BASE} />
        <circle cx="53" cy="13" r="3.2" fill="currentColor" stroke="none" />
      </svg>
    ),
  },
  {
    title: "Edge cache",
    body: "Responses served from the nearest of 300 regions.",
    icon: (
      <svg {...svgProps}>
        <path d="M40 4 70 14 40 24 10 14Z" stroke={FAINT} />
        <path d="M10 22 40 32 70 22" stroke={FAINT} />
        <path d="M10 30 40 40 70 30" stroke={BASE} />
        <path d="M40 14 55 19 40 24 25 19Z" fill="currentColor" stroke="none" />
      </svg>
    ),
  },
  {
    title: "Audit trail",
    body: "Who changed what, and when, kept for good.",
    icon: (
      <svg {...svgProps}>
        <circle cx="10" cy="9" r="2.5" fill="currentColor" stroke="none" />
        <path d="M22 9h70" stroke={BASE} />
        <circle cx="10" cy="22" r="2.5" stroke={BASE} />
        <path d="M22 22h104" stroke={FAINT} />
        <circle cx="10" cy="35" r="2.5" stroke={BASE} />
        <path d="M22 35h52" stroke={FAINT} />
      </svg>
    ),
  },
];

type Props = {
  /** Reach of the light in px. */
  radius?: number;
  /** Grid columns (1-4). The first card spans two when there is room. */
  columns?: number;
  /** Peak alpha of the surface wash, 0-1. */
  intensity?: number;
  /** Light colour as #rrggbb. */
  color?: string;
  /** Let the light drift slowly while nobody is pointing at the grid. */
  idle?: boolean;
  items?: SpotlightItem[];
  className?: string;
};

function parseColor(c: string): [number, number, number] {
  const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(c);
  if (!m) return [255, 45, 45];
  return [parseInt(m[1] ?? "ff", 16), parseInt(m[2] ?? "2d", 16), parseInt(m[3] ?? "2d", 16)];
}

export function SpotlightGrid({
  radius = 220,
  columns = 2,
  intensity = 0.16,
  color = "#ff2d2d",
  idle = true,
  items = DEFAULT_ITEMS,
  className = "",
}: Props) {
  const rootRef = useRef<HTMLUListElement>(null);
  // Shared input state: written by handlers, read by the frame loop.
  const ptr = useRef({ hover: false, x: 0, y: 0, focus: false, fx: 0, fy: 0 });
  const wakeRef = useRef<() => void>(() => {});
  const live = useRef({ idle, radius });

  const cols = Math.max(1, Math.min(4, Math.round(columns) || 1));
  const [r, g, b] = parseColor(color);
  const n = items.length;

  // Bento spans: the first card is wide, the last one soaks up any gap in the final row.
  const spans = items.map((_, i): number => (i === 0 && cols >= 2 && n > 2 ? 2 : 1));
  const total = spans.reduce((s, v) => s + v, 0);
  if (n > 1 && cols > 1) spans[n - 1] = (spans[n - 1] ?? 1) + ((cols - (total % cols)) % cols);

  useEffect(() => {
    live.current = { idle, radius };
    wakeRef.current();
  }, [idle, radius]);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const cards = Array.from(root.querySelectorAll<HTMLElement>("[data-spot]"));
    let rects: { l: number; t: number; w: number; h: number }[] = [];
    let W = 1;
    let H = 1;
    const measure = () => {
      W = root.offsetWidth || 1;
      H = root.offsetHeight || 1;
      rects = cards.map((c) => ({ l: c.offsetLeft, t: c.offsetTop, w: c.offsetWidth, h: c.offsetHeight }));
    };
    measure();

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;
    let x = W * 0.3;
    let y = H * 0.34;
    let o = 0;
    let last = 0;
    let raf = 0;
    let visible = true;

    const apply = () => {
      root.style.setProperty("--gx", x.toFixed(1) + "px");
      root.style.setProperty("--gy", y.toFixed(1) + "px");
      root.style.setProperty("--o", o.toFixed(3));
      const reach = live.current.radius * 1.2;
      cards.forEach((c, i) => {
        const q = rects[i];
        if (!q) return;
        const dx = Math.max(q.l - x, 0, x - q.l - q.w);
        const dy = Math.max(q.t - y, 0, y - q.t - q.h);
        const lit = Math.pow(Math.max(0, 1 - Math.hypot(dx, dy) / reach), 1.6) * o;
        c.style.setProperty("--x", (x - q.l).toFixed(1) + "px");
        c.style.setProperty("--y", (y - q.t).toFixed(1) + "px");
        c.style.setProperty("--lit", lit.toFixed(3));
      });
    };

    const frame = (ts: number) => {
      raf = 0;
      if (!visible) return;
      const dt = last ? Math.min(0.05, (ts - last) / 1000) : 0.016;
      last = ts;
      const p = ptr.current;
      const drifting = live.current.idle && !p.hover && !p.focus && !reduced;
      let tx = x;
      let ty = y;
      let to = 0;
      let rate = 4;
      if (p.hover) {
        tx = p.x;
        ty = p.y;
        to = 1;
        rate = reduced ? 60 : 16;
      } else if (p.focus) {
        tx = p.fx;
        ty = p.fy;
        to = 1;
        rate = reduced ? 60 : 8;
      } else if (live.current.idle) {
        const s = ts / 1000;
        tx = reduced ? W * 0.3 : W * (0.5 + 0.36 * Math.sin(s * 0.37));
        ty = reduced ? H * 0.34 : H * (0.5 + 0.3 * Math.sin(s * 0.53 + 1.2));
        to = 0.85;
        rate = reduced ? 60 : 3;
      }
      const k = 1 - Math.exp(-dt * rate);
      x += (tx - x) * k;
      y += (ty - y) * k;
      o += (to - o) * (1 - Math.exp(-dt * (reduced ? 60 : 7)));
      apply();
      const settled = Math.abs(tx - x) < 0.3 && Math.abs(ty - y) < 0.3 && Math.abs(to - o) < 0.004;
      if (!settled || drifting) raf = requestAnimationFrame(frame);
      else last = 0;
    };

    const wake = () => {
      if (raf || !visible) return;
      last = 0;
      raf = requestAnimationFrame(frame);
    };
    wakeRef.current = wake;

    const onMq = () => {
      reduced = mq.matches;
      wake();
    };
    mq.addEventListener("change", onMq);
    const ro = new ResizeObserver(() => {
      measure();
      wake();
    });
    ro.observe(root);
    const io = new IntersectionObserver(
      (entries) => {
        visible = entries.some((e) => e.isIntersecting);
        if (visible) wake();
        else if (raf) {
          cancelAnimationFrame(raf);
          raf = 0;
        }
      },
      { rootMargin: "100px" },
    );
    io.observe(root);
    wake();

    return () => {
      wakeRef.current = () => {};
      if (raf) cancelAnimationFrame(raf);
      mq.removeEventListener("change", onMq);
      ro.disconnect();
      io.disconnect();
    };
  }, [n, cols]);

  const local = (e: ReactPointerEvent<HTMLUListElement>) => {
    const el = rootRef.current;
    if (!el) return;
    const box = el.getBoundingClientRect();
    ptr.current.x = (e.clientX - box.left) * (el.offsetWidth / (box.width || 1));
    ptr.current.y = (e.clientY - box.top) * (el.offsetHeight / (box.height || 1));
  };
  const enter = (e: ReactPointerEvent<HTMLUListElement>) => {
    local(e);
    ptr.current.hover = true;
    wakeRef.current();
  };
  const leave = () => {
    ptr.current.hover = false;
    wakeRef.current();
  };

  const wash = "radial-gradient(" + radius + "px circle at var(--x, 50%) var(--y, 50%), rgba(" + r + "," + g + "," + b + "," + intensity + "), transparent 70%)";
  const edgeA = Math.min(1, (intensity / 0.16) * 1).toFixed(2);
  const edge =
    "radial-gradient(" + Math.round(radius * 0.45) + "px circle at var(--x, 50%) var(--y, 50%), rgba(255,255,255," + (Number(edgeA) * 0.7).toFixed(2) + "), transparent 100%), " +
    "radial-gradient(" + Math.round(radius * 0.95) + "px circle at var(--x, 50%) var(--y, 50%), rgba(" + r + "," + g + "," + b + "," + edgeA + "), transparent 72%)";
  const bleed =
    "radial-gradient(" + Math.round(radius * 1.15) + "px circle at calc(var(--gx, 50%) + 24px) calc(var(--gy, 50%) + 24px), rgba(" + r + "," + g + "," + b + "," + (intensity * 0.85).toFixed(3) + "), transparent 70%)";

  return (
    <ul
      ref={rootRef}
      onPointerEnter={enter}
      onPointerMove={(e) => {
        local(e);
        if (!ptr.current.hover) ptr.current.hover = true;
        wakeRef.current();
      }}
      onPointerDown={enter}
      onPointerUp={(e) => {
        if (e.pointerType !== "mouse") leave();
      }}
      onPointerCancel={leave}
      onPointerLeave={leave}
      className={"relative m-0 grid w-full list-none gap-3 p-0 " + className}
      style={{ gridTemplateColumns: "repeat(" + cols + ", minmax(0, 1fr))", touchAction: "pan-y" }}
    >
      <span
        aria-hidden
        className="pointer-events-none absolute -inset-6"
        style={{ background: bleed, opacity: "var(--o, 0)" }}
      />
      {items.map((item, i) => (
        <li
          key={item.title}
          data-spot=""
          tabIndex={0}
          onFocus={(e) => {
            const t = e.currentTarget;
            ptr.current.focus = true;
            ptr.current.fx = t.offsetLeft + t.offsetWidth / 2;
            ptr.current.fy = t.offsetTop + t.offsetHeight / 2;
            wakeRef.current();
          }}
          onBlur={() => {
            ptr.current.focus = false;
            wakeRef.current();
          }}
          className="group relative z-10 flex min-h-[148px] flex-col rounded-2xl p-4 text-left outline-none transition-[translate] duration-300 ease-[cubic-bezier(.2,.8,.2,1)] hover:-translate-y-0.5 active:translate-y-0 focus-visible:!rounded-2xl focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-white/70 motion-reduce:transition-none motion-reduce:hover:translate-y-0 @md:p-5"
          style={{
            gridColumn: "span " + (spans[i] ?? 1) + " / span " + (spans[i] ?? 1),
            background: "linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.012)), #0b0c0f",
            border: "1px solid rgba(255,255,255,0.07)",
            boxShadow: "0 20px 40px -22px rgb(" + r + " " + g + " " + b + " / calc(var(--lit, 0) * 0.45))",
          }}
        >
          <span
            aria-hidden
            className="pointer-events-none absolute inset-0 rounded-[15px]"
            style={{ background: wash, opacity: "var(--o, 0)" }}
          />
          <span
            aria-hidden
            className="pointer-events-none absolute -inset-px rounded-2xl p-px"
            style={{
              background: edge,
              opacity: "var(--o, 0)",
              WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
              WebkitMaskComposite: "xor",
              mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
              maskComposite: "exclude",
            }}
          />
          <div
            aria-hidden
            className="relative mb-auto pb-4"
            style={{ color: "rgb(" + r + "," + g + "," + b + ")", opacity: "calc(0.55 + var(--lit, 0) * 0.45)" }}
          >
            {item.icon}
          </div>
          <div className="relative">
            <h4 className="m-0 text-[15px] font-semibold tracking-tight text-white/90">{item.title}</h4>
            <p className="m-0 mt-1 text-[13px] leading-5 text-white/50">{item.body}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

A bento of five dark cards, the first spanning two columns, lit by a single light. The light is one point in the grid’s own coordinate space, measured from the pointer in layout pixels so it stays correct when a card scales the preview down. A small frame loop eases that point toward the pointer, and each frame writes CSS variables: the shared position and fade on the grid, and per card the light’s position inside the card plus a proximity value from 0 to 1. The card paints three layers from them: a soft surface wash, a one-pixel border glow cut out with a mask so only the edge lights up (with a white-hot core inside it), and a soft shadow. A fourth glow sits behind the whole grid so the light bleeds across the gaps and reads as one source, not five. Each card’s illustration brightens with its proximity, and the card lifts two pixels on hover. With nobody pointing, the light drifts slowly on a Lissajous path at most of its strength, so a thumbnail or a static page is never dark; the pointer takes over with a short glide and the light fades out when it leaves. On touch the light follows the finger and fades on release, while vertical scrolling still works. Keyboard focus lights the focused card from its centre. The loop sleeps when it has settled, when the grid is off screen and when the tab is hidden. Props: radius, columns, intensity, color (a #rrggbb), idle, items (title, body, optional icon) and className.

Curator’s note

Rebuilt in October as a dark bento with one light that is shared by every card, reaches the borders and illustrations, and drifts on its own until you take over. One file, React only.

Pairs well with

Pro