{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pixel-heading-character",
  "type": "registry:ui",
  "description": "Per-character pixel-font heading with four animation modes using Geist pixel fonts",
  "dependencies": [
    "geist"
  ],
  "files": [
    {
      "path": "registry/default/ui/pixel-heading-character.tsx",
      "content": "/**\n * @module PixelHeading (Character variant)\n *\n * Per-character pixel-font heading with four animation modes.\n *\n * Setup — Geist Pixel Fonts with Tailwind CSS\n * =============================================\n *\n * All Geist fonts can be used through CSS variables:\n *\n *   GeistSans:          --font-geist-sans\n *   GeistMono:          --font-geist-mono\n *   GeistPixelSquare:   --font-geist-pixel-square\n *   GeistPixelGrid:     --font-geist-pixel-grid\n *   GeistPixelCircle:   --font-geist-pixel-circle\n *   GeistPixelTriangle: --font-geist-pixel-triangle\n *   GeistPixelLine:     --font-geist-pixel-line\n *\n * 1. Register the font variables in app/layout.js:\n *\n *   ```js\n *   import { GeistSans } from \"geist/font/sans\";\n *   import { GeistMono } from \"geist/font/mono\";\n *   import { GeistPixelSquare } from \"geist/font/pixel\";\n *\n *   export default function RootLayout({ children }) {\n *     return (\n *       <html\n *         lang=\"en\"\n *         className={`${GeistSans.variable} ${GeistMono.variable} ${GeistPixelSquare.variable}`}\n *       >\n *         <body>{children}</body>\n *       </html>\n *     );\n *   }\n *   ```\n *\n * 2. Map the CSS variables in your Tailwind CSS v4 theme (tailwind.css):\n *\n *   ```css\n *   @theme {\n *     --font-sans: var(--font-geist-sans);\n *     --font-mono: var(--font-geist-mono);\n *     --font-pixel-square: var(--font-geist-pixel-square);\n *     --font-pixel-grid: var(--font-geist-pixel-grid);\n *     --font-pixel-circle: var(--font-geist-pixel-circle);\n *     --font-pixel-triangle: var(--font-geist-pixel-triangle);\n *     --font-pixel-line: var(--font-geist-pixel-line);\n *   }\n *   ```\n *\n * Once configured, the `font-pixel-*` utility classes used by this\n * component will resolve correctly.\n */\n\n\"use client\"\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/* ─── Constants ─── */\n\nconst PIXEL_FONTS = [\n  \"font-pixel-square\",\n  \"font-pixel-grid\",\n  \"font-pixel-circle\",\n  \"font-pixel-triangle\",\n  \"font-pixel-line\",\n] as const\n\nconst FONT_LABELS = [\"Square\", \"Grid\", \"Circle\", \"Triangle\", \"Line\"] as const\nconst FONT_COUNT = PIXEL_FONTS.length\n\n/** Map short name → Tailwind class for the prefix font. */\nconst PREFIX_FONT_MAP: Record<string, string> = {\n  square: \"font-pixel-square\",\n  grid: \"font-pixel-grid\",\n  circle: \"font-pixel-circle\",\n  triangle: \"font-pixel-triangle\",\n  line: \"font-pixel-line\",\n}\n\n/** Map short name → Tailwind class for isolated (non-pixel) characters. */\nconst ISOLATE_FONT_MAP: Record<string, string> = {\n  sans: \"font-sans\",\n  mono: \"font-mono\",\n}\n\nfunction resolveIsolateFont(value: string): string {\n  return ISOLATE_FONT_MAP[value] ?? value\n}\n\n/** Golden ratio — maximises spacing between identical values in a sequence. */\nconst PHI = (1 + Math.sqrt(5)) / 2\n\n/** Internal tick rate in ms — drives the stagger resolution. */\nconst TICK_MS = 50\n\n/* ─── Distribution algorithms ─── */\n\n/**\n * Golden-ratio–based index.\n * Maps a sequential position to a font index such that\n * adjacent positions almost never share the same font.\n */\nfunction goldenBase(index: number): number {\n  return Math.floor((index * PHI * FONT_COUNT) % FONT_COUNT)\n}\n\n/**\n * Deterministic pseudo-random via Knuth multiplicative hash.\n * Produces a uniform-ish distribution across FONT_COUNT for any (tick, index) pair.\n */\nfunction pseudoRandom(tick: number, index: number): number {\n  return ((tick * 2654435761 + index * 340573321) >>> 0) % FONT_COUNT\n}\n\n/* ─── Helpers ─── */\n\n/** Recursively extract text content from React children. */\nfunction extractText(children: React.ReactNode): string {\n  if (typeof children === \"string\") return children\n  if (typeof children === \"number\") return String(children)\n  if (Array.isArray(children)) return children.map(extractText).join(\"\")\n  if (\n    children !== null &&\n    children !== undefined &&\n    typeof children === \"object\" &&\n    \"props\" in children\n  ) {\n    return extractText(\n      (children as React.ReactElement<{ children?: React.ReactNode }>).props\n        .children\n    )\n  }\n  return \"\"\n}\n\n/* ─── Types ─── */\n\n/**\n * Animation mode for per-character font distribution.\n *\n * | Mode       | At rest                         | On hover                                           |\n * |------------|---------------------------------|----------------------------------------------------|\n * | `uniform`  | Single font (original behavior) | Cycles one font for all characters                 |\n * | `multi`    | Golden-ratio distribution       | Staggered cascade — each char cycles independently |\n * | `wave`     | Position-based gradient         | Fonts flow left→right in a continuous wave         |\n * | `random`   | Golden-ratio distribution       | Each character scrambles independently              |\n */\nexport type PixelHeadingMode = \"uniform\" | \"multi\" | \"wave\" | \"random\"\n\n/* ─── Props ─── */\n\nexport interface PixelHeadingProps extends React.ComponentProps<\"h1\"> {\n  /**\n   * The heading level to render.\n   * @default \"h1\"\n   */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\"\n  /**\n   * Interval in ms between font changes per character.\n   * @default 150\n   */\n  cycleInterval?: number\n  /**\n   * Initial font index (0–4). Only meaningful in `uniform` mode.\n   * @default 0\n   */\n  defaultFontIndex?: number\n  /**\n   * Callback fired when the active font changes (uniform mode only).\n   */\n  onFontIndexChange?: (index: number) => void\n  /**\n   * Whether to show the label beneath the heading.\n   * @default true\n   */\n  showLabel?: boolean\n  /**\n   * Controls how fonts are distributed across characters.\n   *\n   * - `\"uniform\"` — all characters share one font; cycles on hover (original)\n   * - `\"multi\"`   — golden-ratio distribution; staggered cascade on hover\n   * - `\"wave\"`    — fonts flow left-to-right in a continuous wave\n   * - `\"random\"`  — each character scrambles independently per tick\n   *\n   * @default \"multi\"\n   */\n  mode?: PixelHeadingMode\n  /**\n   * Milliseconds of delay between each successive character's animation start.\n   * Creates a left→right cascade / ripple effect.\n   * Only applies in `multi`, `wave`, and `random` modes.\n   * @default 50\n   */\n  staggerDelay?: number\n  /**\n   * When true the animation runs automatically on mount —\n   * no hover or focus required. Hover/focus still work to\n   * restart the cascade.\n   * @default false\n   */\n  autoPlay?: boolean\n  /**\n   * Static text rendered before the animated children.\n   * Does not animate — stays locked to the font set by `prefixFont`.\n   * A trailing space is added automatically.\n   */\n  prefix?: string\n  /**\n   * Which pixel font to apply to the `prefix` text.\n   * Set to `\"none\"` to use the inherited font (e.g. font-sans).\n   * @default \"none\"\n   */\n  prefixFont?: \"square\" | \"grid\" | \"circle\" | \"triangle\" | \"line\" | \"none\"\n  /**\n   * Map of characters to exclude from pixel-font animation.\n   * Keys are single characters (case-sensitive).\n   * Values are font short-names (\"sans\" | \"mono\") or arbitrary\n   * Tailwind font class names (e.g. \"font-serif\").\n   *\n   * Isolated characters always render in their assigned font,\n   * even during hover/auto-play animation.\n   */\n  isolate?: Record<string, string>\n}\n\n/* ─── Component ─── */\n\n/**\n * Interactive heading where **each character** can display a different\n * pixel font. On hover the fonts animate — the distribution algorithm\n * and cascade timing are controlled via the `mode` and `staggerDelay` props.\n *\n * @example\n * ```tsx\n * <PixelHeading mode=\"multi\" className=\"text-7xl\">\n *   Shadcn, expanded\n * </PixelHeading>\n * ```\n *\n * @example\n * ```tsx\n * <PixelHeading mode=\"wave\" cycleInterval={100} staggerDelay={30}>\n *   Wave effect\n * </PixelHeading>\n * ```\n *\n * @example\n * ```tsx\n * <PixelHeading mode=\"multi\" autoPlay>\n *   Runs on mount, no hover needed\n * </PixelHeading>\n * ```\n *\n * @example\n * ```tsx\n * <PixelHeading prefix=\"Shadcn,\" prefixFont=\"grid\" mode=\"wave\" autoPlay>\n *   expanded\n * </PixelHeading>\n * ```\n *\n * @example\n * ```tsx\n * <PixelHeading\n *   prefix=\"Shadcn,\"\n *   prefixFont=\"grid\"\n *   isolate={{ x: \"sans\", h: \"mono\" }}\n *   mode=\"wave\"\n *   autoPlay\n * >\n *   expanded\n * </PixelHeading>\n * ```\n *\n * @example\n * ```tsx\n * <PixelHeading mode=\"uniform\" className=\"text-6xl\">\n *   Classic single-font cycle\n * </PixelHeading>\n * ```\n */\nexport function PixelHeading({\n  children,\n  as: Tag = \"h1\",\n  className,\n  cycleInterval = 150,\n  defaultFontIndex = 0,\n  onFontIndexChange,\n  showLabel = false,\n  mode = \"multi\",\n  staggerDelay = 50,\n  autoPlay = false,\n  prefix,\n  prefixFont = \"none\",\n  isolate,\n  onMouseEnter,\n  onMouseLeave,\n  onFocus,\n  onBlur,\n  onKeyDown,\n  ...props\n}: PixelHeadingProps) {\n  const text = useMemo(() => extractText(children), [children])\n\n  const [msElapsed, setMsElapsed] = useState(0)\n  const [isActive, setIsActive] = useState(false)\n  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)\n  const prevUniformIndex = useRef(defaultFontIndex)\n\n  /* ── Cleanup ── */\n  useEffect(() => {\n    return () => {\n      if (intervalRef.current) clearInterval(intervalRef.current)\n    }\n  }, [])\n\n  /* ── Auto-play: start cycling on mount ── */\n  useEffect(() => {\n    if (!autoPlay) return\n    // Kick off the interval immediately\n    setIsActive(true)\n    setMsElapsed(0)\n    intervalRef.current = setInterval(() => {\n      setMsElapsed((prev) => prev + TICK_MS)\n    }, TICK_MS)\n\n    return () => {\n      if (intervalRef.current) {\n        clearInterval(intervalRef.current)\n        intervalRef.current = null\n      }\n    }\n  }, [autoPlay])\n\n  /* ── Compute per-character font indices ── */\n  const charFonts = useMemo(() => {\n    const fonts: number[] = []\n    let vi = 0 // visible-character index (skips spaces)\n\n    for (let i = 0; i < text.length; i++) {\n      if (text[i] === \" \") {\n        fonts.push(-1)\n        continue\n      }\n\n      switch (mode) {\n        case \"uniform\": {\n          const cycles = Math.floor(msElapsed / cycleInterval)\n          const idx = (defaultFontIndex + cycles) % FONT_COUNT\n          fonts.push(idx)\n          break\n        }\n        case \"multi\": {\n          const base = goldenBase(vi)\n          const charMs = Math.max(0, msElapsed - vi * staggerDelay)\n          const cycles = Math.floor(charMs / cycleInterval)\n          fonts.push((base + cycles) % FONT_COUNT)\n          break\n        }\n        case \"wave\": {\n          const charMs = Math.max(0, msElapsed - vi * staggerDelay)\n          const cycles = Math.floor(charMs / cycleInterval)\n          fonts.push((vi + cycles) % FONT_COUNT)\n          break\n        }\n        case \"random\": {\n          const charMs = Math.max(0, msElapsed - vi * staggerDelay)\n          const cycles = Math.floor(charMs / cycleInterval)\n          fonts.push(cycles > 0 ? pseudoRandom(cycles, vi) : goldenBase(vi))\n          break\n        }\n      }\n      vi++\n    }\n    return fonts\n  }, [text, mode, msElapsed, cycleInterval, staggerDelay, defaultFontIndex])\n\n  /* ── Fire callback for uniform mode ── */\n  useEffect(() => {\n    if (mode !== \"uniform\") return\n    const idx = charFonts.find((f) => f !== -1) ?? defaultFontIndex\n    if (idx !== prevUniformIndex.current) {\n      prevUniformIndex.current = idx\n      onFontIndexChange?.(idx)\n    }\n  }, [charFonts, mode, defaultFontIndex, onFontIndexChange])\n\n  /* ── Label text ── */\n  const activeLabel = useMemo(() => {\n    if (mode === \"uniform\") {\n      const idx = charFonts.find((f) => f !== -1) ?? 0\n      return FONT_LABELS[idx]\n    }\n    const modeLabels: Record<PixelHeadingMode, string> = {\n      uniform: \"\",\n      multi: \"Multi\",\n      wave: \"Wave\",\n      random: \"Random\",\n    }\n    return modeLabels[mode]\n  }, [mode, charFonts])\n\n  /* ── Start / stop cycling ── */\n  const startCycling = useCallback(() => {\n    // Clear any existing interval first to avoid stacking\n    if (intervalRef.current) {\n      clearInterval(intervalRef.current)\n      intervalRef.current = null\n    }\n    setIsActive(true)\n    setMsElapsed(0)\n    intervalRef.current = setInterval(() => {\n      setMsElapsed((prev) => prev + TICK_MS)\n    }, TICK_MS)\n  }, [])\n\n  const stopCycling = useCallback(() => {\n    if (autoPlay) {\n      // Auto-play keeps running — just restart the cascade\n      setIsActive(true)\n      return\n    }\n    setIsActive(false)\n    if (intervalRef.current) {\n      clearInterval(intervalRef.current)\n      intervalRef.current = null\n    }\n  }, [autoPlay])\n\n  /* ── Event handlers ── */\n  const handleMouseEnter = useCallback(\n    (e: React.MouseEvent<HTMLHeadingElement>) => {\n      startCycling()\n      onMouseEnter?.(e)\n    },\n    [startCycling, onMouseEnter]\n  )\n\n  const handleMouseLeave = useCallback(\n    (e: React.MouseEvent<HTMLHeadingElement>) => {\n      stopCycling()\n      onMouseLeave?.(e)\n    },\n    [stopCycling, onMouseLeave]\n  )\n\n  const handleFocus = useCallback(\n    (e: React.FocusEvent<HTMLHeadingElement>) => {\n      startCycling()\n      onFocus?.(e)\n    },\n    [startCycling, onFocus]\n  )\n\n  const handleBlur = useCallback(\n    (e: React.FocusEvent<HTMLHeadingElement>) => {\n      stopCycling()\n      onBlur?.(e)\n    },\n    [stopCycling, onBlur]\n  )\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent<HTMLHeadingElement>) => {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault()\n        setMsElapsed((prev) => prev + cycleInterval)\n      }\n      onKeyDown?.(e)\n    },\n    [cycleInterval, onKeyDown]\n  )\n\n  /* ── Uniform font index (for class on the Tag itself) ── */\n  const uniformIdx =\n    mode === \"uniform\"\n      ? (charFonts.find((f) => f !== -1) ?? defaultFontIndex)\n      : 0\n\n  return (\n    <div\n      data-slot=\"pixel-heading\"\n      className=\"inline-flex flex-col items-start gap-2\"\n    >\n      <Tag\n        data-state={isActive ? \"active\" : \"idle\"}\n        data-mode={mode}\n        aria-label={prefix ? `${prefix} ${text}` : text}\n        tabIndex={0}\n        className={cn(\n          \"cursor-default select-none\",\n          \"focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none\",\n          mode === \"uniform\" && PIXEL_FONTS[uniformIdx],\n          className\n        )}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n        onFocus={handleFocus}\n        onBlur={handleBlur}\n        onKeyDown={handleKeyDown}\n        {...props}\n      >\n        {/* ── Static prefix ── */}\n        {prefix && (\n          <>\n            {isolate ? (\n              prefix.split(\"\").map((char, i) => (\n                <span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence\n                  key={`p${i}`}\n                  className={cn(\n                    prefixFont !== \"none\"\n                      ? PREFIX_FONT_MAP[prefixFont]\n                      : undefined,\n                    isolate[char]\n                      ? resolveIsolateFont(isolate[char])\n                      : undefined\n                  )}\n                  aria-hidden\n                >\n                  {char}\n                </span>\n              ))\n            ) : (\n              <span\n                className={\n                  prefixFont !== \"none\"\n                    ? PREFIX_FONT_MAP[prefixFont]\n                    : undefined\n                }\n                aria-hidden\n              >\n                {prefix}\n              </span>\n            )}\n            <span> </span>\n          </>\n        )}\n\n        {/* ── Animated characters ── */}\n        {mode === \"uniform\"\n          ? children\n          : text.split(\"\").map((char, i) =>\n              char === \" \" ? (\n                // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence\n                <span key={i}> </span>\n              ) : isolate?.[char] ? (\n                <span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence\n                  key={i}\n                  className={resolveIsolateFont(isolate[char])}\n                  aria-hidden\n                >\n                  {char}\n                </span>\n              ) : (\n                <span\n                  // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence\n                  key={i}\n                  className={PIXEL_FONTS[charFonts[i]]}\n                  aria-hidden\n                >\n                  {char}\n                </span>\n              )\n            )}\n      </Tag>\n      {showLabel && (\n        <output\n          data-slot=\"pixel-heading-label\"\n          aria-live=\"polite\"\n          className={cn(\n            \"text-muted-foreground text-xs tracking-widest uppercase transition-opacity duration-200\",\n            isActive || autoPlay ? \"opacity-100\" : \"opacity-0\"\n          )}\n        >\n          {activeLabel}\n        </output>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ]
}