Skip to content

Dot Burst Feature Tabs

Autoplaying feature tabs beside a color stage with dot bursts and a product card.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/dot-burst-feature-tabs.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/glyph/canvas-loop.ts
    /**
     * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped
     * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and
     * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame.
     * Framework-free: start it in an effect and dispose it in the cleanup. Browser only.
     */
    
    export interface CanvasLoopFrame {
      /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */
      dt: number;
      /** Seconds the loop has been running. */
      time: number;
      /** True under reduced motion: draw a calm, finished-looking frame. */
      still: boolean;
      /** Host size in CSS pixels. */
      width: number;
      height: number;
      /** Device pixel ratio of the canvas (capped). */
      dpr: number;
    }
    
    export interface CanvasLoopOptions {
      /** Element observed for size and visibility. */
      host: HTMLElement;
      /** Canvas sized to the host times the pixel ratio. */
      canvas: HTMLCanvasElement;
      /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */
      render: (frame: CanvasLoopFrame) => void;
      /** Called after the canvas is resized, before it is redrawn. */
      resize?: (frame: CanvasLoopFrame) => void;
      /** Read before every frame; while it returns true the loop stops and the last frame stays. */
      isPaused?: () => boolean;
      /** Highest device pixel ratio used. @default 2 */
      maxDpr?: number;
      /** Margin around the viewport that still counts as on screen. @default "120px" */
      rootMargin?: string;
    }
    
    export interface CanvasLoop {
      /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */
      wake(): void;
      /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */
      redraw(): void;
      /** Stops the loop and removes every observer and listener. */
      dispose(): void;
    }
    
    export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop {
      const { host, canvas, rootMargin = "120px" } = options;
      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
      let width = 0;
      let height = 0;
      let dpr = 1;
      let raf = 0;
      let last = 0;
      let time = 0;
      let inView = false;
      let disposed = false;
    
      const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr });
      const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.();
    
      const tick = (now: number) => {
        raf = 0;
        if (!shouldRun()) return;
        const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
        last = now;
        time += dt;
        options.render(frame(dt));
        raf = requestAnimationFrame(tick);
      };
    
      const wake = () => {
        if (disposed) return;
        if (shouldRun()) {
          if (!raf) {
            last = 0;
            raf = requestAnimationFrame(tick);
          }
          return;
        }
        if (raf) cancelAnimationFrame(raf);
        raf = 0;
      };
    
      const redraw = () => {
        if (!disposed && !raf && width > 0) options.render(frame(0));
      };
    
      const resize = () => {
        width = Math.max(1, host.clientWidth);
        height = Math.max(1, host.clientHeight);
        dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2);
        const w = Math.max(1, Math.round(width * dpr));
        const h = Math.max(1, Math.round(height * dpr));
        if (canvas.width !== w || canvas.height !== h) {
          canvas.width = w;
          canvas.height = h;
        }
        options.resize?.(frame(0));
        // Resizing clears the canvas, so repaint right away even while running.
        options.render(frame(0));
      };
    
      const resizeObserver = new ResizeObserver(resize);
      const intersection = new IntersectionObserver(
        ([entry]) => {
          inView = entry?.isIntersecting ?? true;
          wake();
        },
        { rootMargin },
      );
      const onVisibility = () => wake();
      const onMotionChange = () => {
        wake();
        redraw();
      };
    
      resize();
      resizeObserver.observe(host);
      intersection.observe(host);
      document.addEventListener("visibilitychange", onVisibility);
      reducedMotion.addEventListener("change", onMotionChange);
    
      return {
        wake,
        redraw,
        dispose() {
          disposed = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          resizeObserver.disconnect();
          intersection.disconnect();
          document.removeEventListener("visibilitychange", onVisibility);
          reducedMotion.removeEventListener("change", onMotionChange);
        },
      };
    }
    lib/random.ts
    /**
     * Seeded randomness for visuals: dot layouts, characters' moods, print glitches. The same seed
     * gives the same sequence on the server and in the first client frame, so nothing shifts or
     * mismatches on hydration. Not for anything security-related.
     */
    
    /** A 32-bit hash of a string or number, to turn a `seed` prop into a generator state. */
    export function hashSeed(seed: string | number): number {
      const text = typeof seed === "number" ? `n${seed}` : seed;
      // FNV-1a, then a final avalanche so short, similar seeds still diverge.
      let h = 0x811c9dc5;
      for (let i = 0; i < text.length; i++) {
        h ^= text.charCodeAt(i);
        h = Math.imul(h, 0x01000193);
      }
      h ^= h >>> 16;
      h = Math.imul(h, 0x7feb352d);
      h ^= h >>> 15;
      return h >>> 0;
    }
    
    /** mulberry32: a small, fast generator of floats in [0, 1). */
    export function mulberry32(state: number): () => number {
      let a = state >>> 0;
      return () => {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = a;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
    }
    
    export interface Random {
      /** A float in [0, 1). */
      next(): number;
      /** A float in [min, max). */
      range(min: number, max: number): number;
      /** An integer from min to max, both included. */
      int(min: number, max: number): number;
      /** True with probability `p`. */
      chance(p: number): boolean;
      /** One item of a non-empty list. */
      pick<T>(list: readonly T[]): T;
      /** An independent generator derived from this seed and a salt, e.g. one per word or per coin. */
      fork(salt: string | number): Random;
    }
    
    /** A seeded generator with a few helpers on top of `mulberry32`. */
    export function createRandom(seed: string | number): Random {
      const base = hashSeed(seed);
      const next = mulberry32(base);
      return {
        next,
        range: (min, max) => min + next() * (max - min),
        int: (min, max) => min + Math.floor(next() * (max - min + 1)),
        chance: (p) => next() < p,
        pick: (list) => list[Math.floor(next() * list.length)],
        fork: (salt) => createRandom(`${base}:${salt}`),
      };
    }
  3. Copy the source

    components/ui/dot-burst-feature-tabs/dot-burst-feature-tabs.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react";
    import { AnimatePresence, motion, useMotionValue, useReducedMotion } from "motion/react";
    import { Coffee, Pause, Play, ShieldCheck, Sun, Users, Wallet, Zap } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { cssColorToRgb, mixOklch, observeTheme, type Vec3 } from "@/lib/color";
    import { runCanvasLoop } from "@/lib/glyph/canvas-loop";
    import { createRandom } from "@/lib/random";
    
    export interface DotBurstFeature {
      /** Stable id, reported by `onActiveChange`. */
      id: string;
      /** Title in the list. */
      title: string;
      /** One line shown when the item is open. */
      description: string;
      /** Saturated stage color for this feature (any CSS color). */
      color: string;
      /** Product card shown in the middle of the stage, as real DOM. `FeatureNote` builds one. */
      card: ReactNode;
    }
    
    export interface DotBurstFeatureTabsProps extends Omit<ComponentProps<"section">, "children"> {
      /** Heading above the list. @default "Everything moves while you're away." */
      heading?: ReactNode;
      /** The features. @default six fictional money features */
      items?: DotBurstFeature[];
      /** Seconds each feature stays open while autoplaying. @default 4.5 */
      interval?: number;
      /** Open the next feature when the bar fills. Stops for good once someone picks an item. @default true */
      autoplay?: boolean;
      /** How many dot clusters gather at the edges, 0–1. @default 0.5 */
      dotDensity?: number;
      /** Called with the id of the feature that opens. */
      onActiveChange?: (id: string) => void;
    }
    
    export interface FeatureNoteProps {
      /** Icon in the colored tile, top left. */
      icon?: ReactNode;
      /** App name above the title. @default "Nimbra" */
      app?: string;
      /** Time on the right of the app name. @default "now" */
      time?: string;
      /** Bold first line. */
      title: ReactNode;
      /** Second line. */
      body?: ReactNode;
      /** Highlighted amount or status; pops in a beat after the card. */
      chip?: ReactNode;
      /** Tint of the icon tile and chip. @default "#16a34a" */
      accent?: string;
      /** Extra content under the body, such as a progress bar or avatars. */
      children?: ReactNode;
    }
    
    /**
     * A white notification card for a Dot Burst Feature Tabs stage. Its chip pops in a beat after the
     * card arrives.
     */
    export function FeatureNote({ icon, app = "Nimbra", time = "now", title, body, chip, accent = "#16a34a", children }: FeatureNoteProps) {
      const reduceMotion = useReducedMotion();
      return (
        <div className="w-[min(84%,20rem)] rounded-2xl bg-white p-4 text-left text-[#1c1917] shadow-[0_18px_40px_-12px_rgb(0_0_0/0.35)]">
          <div className="flex items-center gap-2 text-[12px] text-[#6b635d]">
            <span className="grid size-6 place-items-center rounded-md text-white [&_svg]:size-3.5" style={{ background: accent }} aria-hidden="true">
              {icon}
            </span>
            <span className="font-semibold text-[#1c1917]">{app}</span>
            <span className="ml-auto tabular-nums">{time}</span>
          </div>
          <p className="mt-2.5 text-[15px] leading-snug font-semibold">{title}</p>
          {body ? <p className="mt-0.5 text-[13px] leading-snug text-[#57534e]">{body}</p> : null}
          {children ? <div className="mt-3">{children}</div> : null}
          {chip ? (
            <motion.span
              initial={reduceMotion ? false : { opacity: 0, scale: 0.7, y: 4 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              transition={{ type: "spring", stiffness: 420, damping: 22, delay: 0.24 }}
              className="mt-3 inline-flex h-7 origin-left items-center rounded-full px-2.5 text-[12.5px] font-semibold tabular-nums"
              style={{ background: `color-mix(in oklab, ${accent} 14%, white)`, color: `color-mix(in oklab, ${accent} 78%, black)` }}
            >
              {chip}
            </motion.span>
          ) : null}
        </div>
      );
    }
    
    /** A thin goal bar for cards; fills on arrival. */
    export function NoteMeter({ value, accent = "#16a34a", label }: { value: number; accent?: string; label?: ReactNode }) {
      const reduceMotion = useReducedMotion();
      return (
        <div>
          {label ? <div className="mb-1.5 flex justify-between text-[12px] text-[#57534e] tabular-nums">{label}</div> : null}
          <div className="h-1.5 overflow-hidden rounded-full bg-[#ece9e5]" role="img" aria-label={`${Math.round(value * 100)}%`}>
            <motion.div
              className="h-full origin-left rounded-full"
              style={{ background: accent }}
              initial={reduceMotion ? false : { scaleX: 0 }}
              animate={{ scaleX: Math.max(0, Math.min(1, value)) }}
              transition={{ duration: 0.7, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
            />
          </div>
        </div>
      );
    }
    
    const DEFAULT_ITEMS: DotBurstFeature[] = [
      {
        id: "salary",
        title: "Pay yourself first",
        description: "A slice of every paycheck moves to savings the minute it lands.",
        color: "#f2542d",
        card: <FeatureNote icon={<Wallet />} title="Salary landed" body="$3,200.00 from Corvane Studio" chip="+$320.00 to Savings" />,
      },
      {
        id: "roundup",
        title: "Round up the change",
        description: "Card purchases round up to the next dollar, and the difference is saved.",
        color: "#7357ff",
        card: <FeatureNote icon={<Coffee />} title="Flat white, Driftwell Café" body="$4.60 rounded up to $5.00" chip="+$0.40" accent="#7357ff" time="8:12" />,
      },
      {
        id: "overnight",
        title: "Earn while you sleep",
        description: "Idle cash earns interest overnight, paid out every morning.",
        color: "#0e9f9a",
        card: <FeatureNote icon={<Sun />} title="Good morning" body="Your balance worked the night shift." chip="+$1.84 earned while you slept" accent="#0e9f9a" time="6:00" />,
      },
      {
        id: "safe",
        title: "Know what's safe to spend",
        description: "Bills and goals come off the top, so the number you see is yours.",
        color: "#2f6bff",
        card: (
          <FeatureNote icon={<ShieldCheck />} title="$412.00 safe to spend" body="Through Sunday, after rent and bills" accent="#2f6bff" time="Mon">
            <NoteMeter value={0.62} accent="#2f6bff" label={<><span>Spent $252</span><span>of $664</span></>} />
          </FeatureNote>
        ),
      },
      {
        id: "pot",
        title: "Save together",
        description: "Shared pots for trips and gifts, with everyone's progress in one place.",
        color: "#e0407b",
        card: (
          <FeatureNote icon={<Users />} title="Coast trip pot" body="Mira added $40.00" accent="#e0407b" time="2m">
            <div className="mb-3 flex -space-x-1.5" aria-label="Mira, Tomas and Ana">
              {["MI", "TO", "AN"].map((i, n) => (
                <span key={i} className="grid size-7 place-items-center rounded-full border-2 border-white text-[10.5px] font-bold text-white" style={{ background: ["#e0407b", "#7357ff", "#0e9f9a"][n] }}>
                  {i}
                </span>
              ))}
            </div>
            <NoteMeter value={860 / 1200} accent="#e0407b" label={<><span>$860.00 saved</span><span>Goal $1,200</span></>} />
          </FeatureNote>
        ),
      },
      {
        id: "instant",
        title: "Move money instantly",
        description: "Transfers between friends land in seconds, any day of the week.",
        color: "#16a34a",
        card: <FeatureNote icon={<Zap />} title="Sent $50.00 to Rafi" body="Dinner, split three ways" chip="Arrived in 4s" time="Sun" />,
      },
    ];
    
    interface Dot {
      x: number;
      y: number;
      size: number;
      tint: boolean;
      delay: number;
      twinkle: number;
      vx: number;
      vy: number;
    }
    
    /**
     * Seeded dot clusters for one feature: clumps of 2–4px square dots on a grid, gathered along the
     * edges and in the corners, never in the middle of the stage.
     */
    function layoutDots(seed: string, size: number, density: number): Dot[] {
      const r = createRandom(seed);
      const pitch = Math.max(6, size / 60);
      const cells = Math.floor(size / pitch);
      const clusters = Math.round(5 + density * 9);
      const dots: Dot[] = [];
      const taken = new Set<number>();
      for (let c = 0; c < clusters; c++) {
        // Along an edge, or tucked into a corner.
        const corner = r.chance(0.35);
        const along = corner ? r.pick([0.06, 0.94]) : r.range(0.08, 0.92);
        const depth = r.range(0.02, 0.17);
        const side = r.int(0, 3);
        let [cx, cy] = side === 0 ? [along, depth] : side === 1 ? [1 - depth, along] : side === 2 ? [along, 1 - depth] : [depth, along];
        if (corner) [cx, cy] = [cx < 0.5 ? r.range(0.02, 0.12) : r.range(0.88, 0.98), cy < 0.5 ? r.range(0.02, 0.12) : r.range(0.88, 0.98)];
        const radius = r.range(3.2, 8) * (0.75 + density * 0.6);
        const gx = Math.round(cx * cells);
        const gy = Math.round(cy * cells);
        const reach = Math.ceil(radius);
        for (let y = gy - reach; y <= gy + reach; y++) {
          for (let x = gx - reach; x <= gx + reach; x++) {
            if (x < 0 || y < 0 || x >= cells || y >= cells) continue;
            const d = Math.hypot(x - gx, y - gy) / radius;
            // A soft, lumpy falloff makes clumps rather than discs.
            if (d > 1 || r.next() > (1 - d) ** 1.1 * 0.95) continue;
            const u = (x + 0.5) / cells;
            const v = (y + 0.5) / cells;
            if (Math.abs(u - 0.5) < 0.3 && Math.abs(v - 0.5) < 0.3) continue;
            const key = y * cells + x;
            if (taken.has(key)) continue;
            taken.add(key);
            dots.push({
              x: (x + 0.5) * pitch,
              y: (y + 0.5) * pitch,
              size: d < 0.35 ? 4 : d < 0.7 ? 3 : 2,
              tint: r.chance(0.28),
              delay: c * 0.055 + d * 0.12,
              twinkle: r.chance(0.07) ? r.range(0, Math.PI * 2) : -1,
              vx: 0,
              vy: 0,
            });
          }
        }
      }
      return dots;
    }
    
    const ease = (t: number) => 1 - (1 - Math.min(1, Math.max(0, t))) ** 3;
    
    /**
     * A feature section with an autoplaying vertical list on the left and a square color stage on the
     * right. Each feature has its own saturated color, a seeded burst of dot clusters along the stage's
     * edges and a small product card in the middle. The open item's bar fills over `interval` in the
     * feature's color, then the next one opens; changing features tweens the stage color in OKLCH,
     * scatters the old dots, grows the new ones cluster by cluster and blurs the card across.
     *
     * Tabs semantics with arrow keys; a visible pause/play button; autoplay pauses on hover, on focus
     * inside, offscreen and in hidden tabs, and stops for good once someone picks an item. The stage
     * moves above the list on narrow containers, where a swipe changes features. Dots are drawn on one
     * canvas (paused offscreen, still under reduced motion, which also turns off autoplay).
     */
    export function DotBurstFeatureTabs({
      heading = (
        <>
          Everything moves
          <br />
          while you&apos;re away.
        </>
      ),
      items = DEFAULT_ITEMS,
      interval = 4.5,
      autoplay = true,
      dotDensity = 0.5,
      onActiveChange,
      className,
      ...props
    }: DotBurstFeatureTabsProps) {
      const id = useId().replace(/:/g, "");
      const reduceMotion = useReducedMotion();
      const [index, setIndex] = useState(0);
      const [playing, setPlaying] = useState(autoplay);
      const active = items[Math.min(index, items.length - 1)];
      const progress = useMotionValue(0);
      const sectionRef = useRef<HTMLElement>(null);
      const stageRef = useRef<HTMLDivElement>(null);
      const canvasRef = useRef<HTMLCanvasElement>(null);
      const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
      const pause = useRef({ hover: false, focus: false, inView: true });
      // Autoplay runs only with motion allowed and more than one item.
      const autoplaying = playing && !reduceMotion && items.length > 1;
      const live = useRef({ index, items, onActiveChange, dotDensity, autoplaying, interval });
      useEffect(() => {
        live.current = { index, items, onActiveChange, dotDensity, autoplaying, interval };
      });
    
      const go = (next: number, fromUser: boolean) => {
        const i = ((next % items.length) + items.length) % items.length;
        progress.set(0);
        setIndex(i);
        if (fromUser) setPlaying(false);
        onActiveChange?.(items[i].id);
      };
    
      // Feature colors may be theme variables: re-resolve them after a theme switch.
      const [themeKey, setThemeKey] = useState(0);
      useEffect(() => observeTheme(() => setThemeKey((k) => k + 1)), []);
    
      // Stage color: tween in OKLCH so the midpoints stay saturated.
      const colorRef = useRef<{ from: Vec3; to: Vec3; start: number } | null>(null);
      useEffect(() => {
        const stage = stageRef.current;
        if (!stage) return;
        const to = cssColorToRgb(active.color, stage);
        const prev = colorRef.current;
        const current = prev ? mixOklch(prev.from, prev.to, ease((performance.now() - prev.start) / 500)) : to;
        colorRef.current = { from: reduceMotion ? to : current, to, start: performance.now() };
        let raf = 0;
        const paint = () => {
          const c = colorRef.current!;
          const t = ease((performance.now() - c.start) / 500);
          stage.style.backgroundColor = `rgb(${mixOklch(c.from, c.to, t).join(",")})`;
          if (t < 1) raf = requestAnimationFrame(paint);
        };
        paint();
        return () => cancelAnimationFrame(raf);
      }, [active.color, reduceMotion, themeKey]);
    
      // Dots: one canvas, seeded clusters per feature, scattered and regrown on each change.
      const dotsRef = useRef<{ current: Dot[]; leaving: Dot[]; changedAt: number; key: string; tint: string; redraw: () => void } | null>(null);
      useEffect(() => {
        const stage = stageRef.current;
        const canvas = canvasRef.current;
        const ctx = canvas?.getContext("2d");
        if (!stage || !canvas || !ctx) return;
        let size = 1;
        let clock = 0;
        const state = { current: [] as Dot[], leaving: [] as Dot[], changedAt: -10, key: "", tint: "#ffffff", redraw: () => {} };
        dotsRef.current = state;
        const tintFor = (feature: DotBurstFeature) => `rgb(${mixOklch(cssColorToRgb(feature.color, stage), [255, 255, 255], 0.55).join(",")})`;
    
        const relayout = (feature: DotBurstFeature, animate: boolean) => {
          const key = `${feature.id}:${Math.round(size)}`;
          if (key === state.key) return;
          const center = size / 2;
          state.leaving = animate
            ? state.current.map((d) => {
                const a = Math.atan2(d.y - center, d.x - center);
                return { ...d, vx: Math.cos(a) * (140 + d.size * 30), vy: Math.sin(a) * (140 + d.size * 30) };
              })
            : [];
          state.current = layoutDots(`${id}:${feature.id}`, size, live.current.dotDensity);
          state.tint = tintFor(feature);
          state.changedAt = animate ? clock : -10;
          state.key = key;
        };
    
        const loop = runCanvasLoop({
          host: stage,
          canvas,
          resize: (frame) => {
            size = Math.min(frame.width, frame.height);
            state.key = "";
            const { items: list, index: i } = live.current;
            relayout(list[Math.min(i, list.length - 1)], false);
          },
          render: ({ dt, still, dpr, width, height }) => {
            clock += dt;
            // The autoplay timer rides this loop (one loop per component): it fills the open item's bar,
            // then opens the next item. The loop only runs in view, in a visible tab and with motion allowed.
            const p = pause.current;
            if (live.current.autoplaying && !p.hover && !p.focus && p.inView) {
              const next = progress.get() + dt / Math.max(0.5, live.current.interval);
              if (next >= 1) {
                const { index: i, items: list, onActiveChange: cb } = live.current;
                const n = (i + 1) % list.length;
                progress.set(0);
                setIndex(n);
                cb?.(list[n].id);
              } else progress.set(next);
            }
            const { items: list, index: i } = live.current;
            relayout(list[Math.min(i, list.length - 1)], !still);
            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
            ctx.clearRect(0, 0, width, height);
            const ox = (width - size) / 2;
            const oy = (height - size) / 2;
            const since = clock - state.changedAt;
            // Old dots fly outward and fade fast.
            if (since < 0.4) {
              for (const d of state.leaving) {
                ctx.globalAlpha = Math.max(0, 1 - since / 0.35);
                ctx.fillStyle = d.tint ? state.tint : "#ffffff";
                ctx.fillRect(ox + d.x + d.vx * since - d.size / 2, oy + d.y + d.vy * since - d.size / 2, d.size, d.size);
              }
            }
            // New clusters grow in one after another; a few dots twinkle at rest.
            for (const d of state.current) {
              const grow = still ? 1 : ease((since - 0.12 - d.delay) / 0.28);
              if (grow <= 0) continue;
              const s = d.size * grow;
              ctx.globalAlpha = d.twinkle >= 0 && !still ? 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(clock * 2.6 + d.twinkle)) : 1;
              ctx.fillStyle = d.tint ? state.tint : "#ffffff";
              ctx.fillRect(ox + d.x - s / 2, oy + d.y - s / 2, s, s);
            }
            ctx.globalAlpha = 1;
          },
        });
        state.redraw = () => loop.redraw();
        const io = new IntersectionObserver(([entry]) => {
          pause.current.inView = entry?.isIntersecting ?? true;
        });
        io.observe(stage);
        // A theme switch only retints the dots; it doesn't burst them again.
        const unobserveTheme = observeTheme(() => {
          const { items: list, index: i } = live.current;
          state.tint = tintFor(list[Math.min(i, list.length - 1)]);
          loop.redraw();
        });
        return () => {
          loop.dispose();
          io.disconnect();
          unobserveTheme();
          dotsRef.current = null;
        };
      }, [id, progress]);
    
      // Redraw the still frame when the feature changes under reduced motion.
      useEffect(() => {
        dotsRef.current?.redraw();
      }, [index, dotDensity]);
    
      const onTabKey = (event: KeyboardEvent<HTMLButtonElement>, i: number) => {
        const map: Record<string, number> = { ArrowDown: i + 1, ArrowRight: i + 1, ArrowUp: i - 1, ArrowLeft: i - 1, Home: 0, End: items.length - 1 };
        if (!(event.key in map)) return;
        event.preventDefault();
        const next = ((map[event.key] % items.length) + items.length) % items.length;
        go(next, true);
        tabRefs.current[next]?.focus();
      };
    
      // Swipe on the stage (mostly for narrow, touch layouts).
      const swipe = useRef<{ x: number; y: number } | null>(null);
    
      const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 260, damping: 30 };
    
      return (
        <section
          ref={sectionRef}
          className={cn("@container w-full", className)}
          onPointerEnter={(e) => {
            if (e.pointerType === "mouse") pause.current.hover = true;
          }}
          onPointerLeave={() => (pause.current.hover = false)}
          onFocus={() => (pause.current.focus = true)}
          onBlur={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) pause.current.focus = false;
          }}
          {...props}
        >
          {/* Narrow: heading, stage, list. Wide: heading and list centered beside the stage. */}
          <div className="grid gap-x-12 gap-y-7 [grid-template-areas:'heading'_'stage'_'list'] @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1.08fr)] @3xl:grid-rows-[1fr_auto_auto_1fr] @3xl:[grid-template-areas:'._stage'_'heading_stage'_'list_stage'_'._stage']">
            <h2 className="text-[clamp(1.75rem,4.4cqi,2.6rem)] leading-[1.08] font-semibold tracking-[-0.02em] text-balance text-foreground [grid-area:heading]">{heading}</h2>
            <div className="flex flex-col gap-7 [grid-area:list]">
              <div role="tablist" aria-orientation="vertical" aria-label="Features" className="flex flex-col">
                {items.map((item, i) => {
                  const open = i === index;
                  return (
                    <div key={item.id} className="border-t border-border first:border-t-0">
                      <motion.div
                        initial={false}
                        animate={{ backgroundColor: open ? "var(--surface)" : "rgba(0,0,0,0)" }}
                        transition={{ duration: reduceMotion ? 0 : 0.2 }}
                        className="my-1 rounded-xl"
                      >
                        <button
                          ref={(el) => {
                            tabRefs.current[i] = el;
                          }}
                          type="button"
                          role="tab"
                          id={`${id}-tab-${i}`}
                          aria-selected={open}
                          aria-controls={`${id}-panel`}
                          tabIndex={open ? 0 : -1}
                          onClick={() => go(i, true)}
                          onKeyDown={(e) => onTabKey(e, i)}
                          className={cn(
                            "flex w-full cursor-pointer items-center px-4 pt-3.5 text-left text-[16px] font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring rounded-xl",
                            open ? "pb-1 text-foreground" : "pb-3.5 text-muted-foreground hover:text-foreground",
                          )}
                        >
                          {item.title}
                        </button>
                        <motion.div initial={false} animate={{ height: open ? "auto" : 0, opacity: open ? 1 : 0 }} transition={{ height: spring, opacity: { duration: reduceMotion ? 0 : 0.2 } }} className="overflow-hidden">
                          <p className="px-4 text-[14px] leading-relaxed text-text-secondary">{item.description}</p>
                          <div className="mx-4 mt-3 mb-4 h-[3px] overflow-hidden rounded-full bg-border" aria-hidden="true">
                            {open ? <motion.div className="h-full origin-left rounded-full" style={{ scaleX: autoplaying ? progress : 1, background: item.color }} /> : null}
                          </div>
                        </motion.div>
                      </motion.div>
                    </div>
                  );
                })}
              </div>
              {items.length > 1 && !reduceMotion ? (
                <button
                  type="button"
                  onClick={() => setPlaying((p) => !p)}
                  aria-label={playing ? "Pause autoplay" : "Play autoplay"}
                  className="inline-flex h-10 w-fit cursor-pointer items-center gap-2 rounded-md border border-input px-3.5 text-[13px] font-semibold text-foreground outline-none hover:bg-surface-2 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.96]"
                >
                  {playing ? <Pause className="size-3.5" aria-hidden="true" /> : <Play className="size-3.5" aria-hidden="true" />}
                  {playing ? "Pause" : "Play"}
                </button>
              ) : null}
            </div>
            <div
              ref={stageRef}
              id={`${id}-panel`}
              role="tabpanel"
              aria-labelledby={`${id}-tab-${index}`}
              className="relative aspect-square w-full touch-pan-y self-center overflow-hidden rounded-3xl [grid-area:stage]"
              style={{ backgroundColor: items[0]?.color }}
              onPointerDown={(e) => (swipe.current = { x: e.clientX, y: e.clientY })}
              onPointerUp={(e) => {
                const s = swipe.current;
                swipe.current = null;
                if (!s) return;
                const dx = e.clientX - s.x;
                if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(e.clientY - s.y)) go(index + (dx < 0 ? 1 : -1), true);
              }}
            >
              <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 size-full" />
              <AnimatePresence initial={false}>
                <motion.div
                  key={active.id}
                  className="absolute inset-0 grid place-items-center"
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, filter: "blur(8px)" }}
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                  exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.97, filter: "blur(8px)" }}
                  transition={{ duration: reduceMotion ? 0.2 : 0.36, ease: [0.22, 1, 0.36, 1] }}
                >
                  {active.card}
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        </section>
      );
    }

Usage

import { DotBurstFeatureTabs, FeatureNote } from "@/components/ui/dot-burst-feature-tabs/dot-burst-feature-tabs";

<DotBurstFeatureTabs
  heading="Weekends, sorted."
  items={[
    {
      id: "plan",
      title: "Plan the route",
      description: "Pick a trailhead and get a loop that fits your afternoon.",
      color: "#0e9f9a",
      card: <FeatureNote title="Ridge loop, 9.4 km" body="Back before sunset" chip="412 m climb" accent="#0e9f9a" />,
    },
    // …
  ]}
/>

API reference

DotBurstFeatureTabs

PropTypeDefaultDescription
headingReactNode"Everything moves while you're away."Heading above the list.
itemsDotBurstFeature[]six fictional money featuresThe features.
intervalnumber4.5Seconds each feature stays open while autoplaying.
autoplaybooleantrueOpen the next feature when the bar fills. Stops for good once someone picks an item.
dotDensitynumber0.5How many dot clusters gather at the edges, 0–1.
onActiveChange(id: string) => void–Called with the id of the feature that opens.

Also accepts every native <section> attribute, including ref and className.

FeatureNote

PropTypeDefaultDescription
iconReactNode–Icon in the colored tile, top left.
appstring"Nimbra"App name above the title.
timestring"now"Time on the right of the app name.
title*ReactNode–Bold first line.
bodyReactNode–Second line.
chipReactNode–Highlighted amount or status; pops in a beat after the card.
accentstring"#16a34a"Tint of the icon tile and chip.
childrenReactNode–Extra content under the body, such as a progress bar or avatars.
  • The stage color tweens over 500ms in OKLCH (`mixOklch` in `src/lib/color.ts`), so the midpoint between, say, orange and violet stays saturated instead of turning brown.
  • Dots are 2–4px squares on a grid, laid out from a seed per feature in clumps along the edges and corners and never in the middle. On a change the old dots fly outward and fade in about a third of a second while the new clusters grow in one after another; a few twinkle at rest. They are drawn on one canvas with the shared canvas loop (DPR capped at 2, paused offscreen and in hidden tabs).
  • `FeatureNote` and `NoteMeter` build the white notification cards; a card's chip pops in a beat after the card blurs in. Any ReactNode works as a card.
  • On narrow containers the stage moves above the list and a horizontal swipe on it changes features.

Accessibility

  • The list is a vertical tablist and the stage its tabpanel: arrow keys (and Home/End) move between features with a roving tabindex, and the open item's title labels the panel.
  • Autoplay has a visible Pause/Play button, pauses while the pointer is over the section, while focus is inside it, while the stage is offscreen and in hidden tabs, and stops for good once someone picks a feature.
  • The cards are real DOM. Under reduced motion there is no autoplay and no dot motion, and cards crossfade without blur or scale.