# Pixel Heading

> Pixel font heading for React and shadcn/ui that cycles each character through Geist pixel fonts in four animation modes.

Source: https://www.cult-ui.com/docs/components/pixel-heading-character

## Example

```tsx title="pixel-heading-character-demo.tsx"
"use client"

import { useState } from "react"

import { PixelHeading } from "@/components/ui/pixel-heading-character"
import type { PixelHeadingMode } from "@/components/ui/pixel-heading-character"

/* ─── Constants ─── */

const MODES: PixelHeadingMode[] = ["uniform", "multi", "wave", "random"]
const PREFIX_FONTS = [
  "none",
  "square",
  "grid",
  "circle",
  "triangle",
  "line",
] as const
const HEADING_LEVELS = ["h1", "h2", "h3", "h4", "h5", "h6"] as const

/* ─── Demo ─── */

export default function PixelHeadingCharacterDemo() {
  const [text, setText] = useState("Pixel Fonts")
  const [mode, setMode] = useState<PixelHeadingMode>("wave")
  const [autoPlay, setAutoPlay] = useState(true)
  const [showLabel, setShowLabel] = useState(true)
  const [cycleInterval, setCycleInterval] = useState(340)
  const [staggerDelay, setStaggerDelay] = useState(200)
  const [prefix, setPrefix] = useState("")
  const [prefixFont, setPrefixFont] =
    useState<(typeof PREFIX_FONTS)[number]>("none")
  const [headingLevel, setHeadingLevel] =
    useState<(typeof HEADING_LEVELS)[number]>("h1")
  const [defaultFontIndex, setDefaultFontIndex] = useState(3)
  const [isolateEnabled, setIsolateEnabled] = useState(false)
  const [isolateChars, setIsolateChars] = useState("x")
  const [isolateFont, setIsolateFont] = useState("sans")

  const isolateMap = isolateEnabled
    ? Object.fromEntries(isolateChars.split("").map((c) => [c, isolateFont]))
    : undefined

  /* ── Reset key forces remount when autoPlay toggles ── */
  const [resetKey, setResetKey] = useState(0)

  return (
    <div className="w-full space-y-8 py-4">
      {/* ── Preview ── */}
      <div className="border-border/40 bg-background flex min-h-[160px] items-center justify-center rounded-lg border p-8">
        <PixelHeading
          key={resetKey}
          as={headingLevel}
          mode={mode}
          autoPlay={autoPlay}
          showLabel={showLabel}
          cycleInterval={cycleInterval}
          staggerDelay={staggerDelay}
          defaultFontIndex={defaultFontIndex}
          prefix={prefix || undefined}
          prefixFont={prefixFont}
          isolate={isolateMap}
          className="text-5xl tracking-tight md:text-7xl"
        >
          {text}
        </PixelHeading>
      </div>

      {/* ── Controls ── */}
      <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {/* Text */}
        <ControlGroup label="Text">
          <input
            type="text"
            value={text}
            onChange={(e) => setText(e.target.value)}
            className="border-input placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border bg-transparent px-3 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none"
            placeholder="Enter heading text"
          />
        </ControlGroup>

        {/* Mode */}
        <ControlGroup label="Mode">
          <div className="flex flex-wrap gap-1.5">
            {MODES.map((m) => (
              <button
                type="button"
                key={m}
                onClick={() => {
                  setMode(m)
                  setResetKey((k) => k + 1)
                }}
                className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
                  mode === m
                    ? "bg-foreground text-background"
                    : "bg-muted text-muted-foreground hover:bg-muted/80"
                }`}
              >
                {m}
              </button>
            ))}
          </div>
        </ControlGroup>

        {/* Heading Level */}
        <ControlGroup label="Heading Level">
          <select
            value={headingLevel}
            onChange={(e) =>
              setHeadingLevel(e.target.value as (typeof HEADING_LEVELS)[number])
            }
            className="border-input focus-visible:ring-ring h-9 w-full rounded-md border bg-transparent px-3 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none"
          >
            {HEADING_LEVELS.map((h) => (
              <option key={h} value={h}>
                {h}
              </option>
            ))}
          </select>
        </ControlGroup>

        {/* Cycle Interval */}
        <ControlGroup label={`Cycle Interval: ${cycleInterval}ms`}>
          <input
            type="range"
            min={30}
            max={500}
            step={10}
            value={cycleInterval}
            onChange={(e) => setCycleInterval(Number(e.target.value))}
            className="accent-foreground w-full"
          />
        </ControlGroup>

        {/* Stagger Delay */}
        <ControlGroup label={`Stagger Delay: ${staggerDelay}ms`}>
          <input
            type="range"
            min={0}
            max={200}
            step={5}
            value={staggerDelay}
            onChange={(e) => setStaggerDelay(Number(e.target.value))}
            className="accent-foreground w-full"
          />
        </ControlGroup>

        {/* Default Font Index (uniform) */}
        <ControlGroup label={`Default Font Index: ${defaultFontIndex}`}>
          <input
            type="range"
            min={0}
            max={4}
            step={1}
            value={defaultFontIndex}
            onChange={(e) => setDefaultFontIndex(Number(e.target.value))}
            className="accent-foreground w-full"
          />
        </ControlGroup>

        {/* Auto Play */}
        <ControlGroup label="Auto Play">
          <Toggle
            checked={autoPlay}
            onChange={(v) => {
              setAutoPlay(v)
              setResetKey((k) => k + 1)
            }}
          />
        </ControlGroup>

        {/* Show Label */}
        <ControlGroup label="Show Label">
          <Toggle checked={showLabel} onChange={setShowLabel} />
        </ControlGroup>

        {/* Prefix */}
        <ControlGroup label="Prefix">
          <input
            type="text"
            value={prefix}
            onChange={(e) => setPrefix(e.target.value)}
            className="border-input placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-full rounded-md border bg-transparent px-3 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none"
            placeholder="e.g. Shadcn,"
          />
        </ControlGroup>

        {/* Prefix Font */}
        <ControlGroup label="Prefix Font">
          <select
            value={prefixFont}
            onChange={(e) =>
              setPrefixFont(e.target.value as (typeof PREFIX_FONTS)[number])
            }
            className="border-input focus-visible:ring-ring h-9 w-full rounded-md border bg-transparent px-3 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none"
          >
            {PREFIX_FONTS.map((f) => (
              <option key={f} value={f}>
                {f}
              </option>
            ))}
          </select>
        </ControlGroup>

        {/* Isolate */}
        <ControlGroup label="Isolate Characters">
          <div className="space-y-2">
            <Toggle checked={isolateEnabled} onChange={setIsolateEnabled} />
            {isolateEnabled && (
              <div className="flex gap-2">
                <input
                  type="text"
                  value={isolateChars}
                  onChange={(e) => setIsolateChars(e.target.value)}
                  className="border-input placeholder:text-muted-foreground focus-visible:ring-ring h-9 w-20 rounded-md border bg-transparent px-3 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none"
                  placeholder="chars"
                />
                <select
                  value={isolateFont}
                  onChange={(e) => setIsolateFont(e.target.value)}
                  className="border-input focus-visible:ring-ring h-9 flex-1 rounded-md border bg-transparent px-3 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none"
                >
                  <option value="sans">sans</option>
                  <option value="mono">mono</option>
                </select>
              </div>
            )}
          </div>
        </ControlGroup>

        {/* Remount */}
        <ControlGroup label="Reset Animation">
          <button
            type="button"
            onClick={() => setResetKey((k) => k + 1)}
            className="border-input hover:bg-muted h-9 rounded-md border bg-transparent px-4 text-sm font-medium shadow-sm transition-colors"
          >
            Restart
          </button>
        </ControlGroup>
      </div>
    </div>
  )
}

/* ─── Shared control primitives ─── */

function ControlGroup({
  label,
  children,
}: {
  label: string
  children: React.ReactNode
}) {
  return (
    <div className="space-y-2">
      <span className="text-muted-foreground block text-xs font-medium tracking-wider uppercase">
        {label}
      </span>
      {children}
    </div>
  )
}

function Toggle({
  checked,
  onChange,
}: {
  checked: boolean
  onChange: (v: boolean) => void
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      onClick={() => onChange(!checked)}
      className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
        checked ? "bg-foreground" : "bg-input"
      }`}
    >
      <span
        className={`bg-background pointer-events-none block h-5 w-5 rounded-full shadow-lg ring-0 transition-transform ${
          checked ? "translate-x-5" : "translate-x-0"
        }`}
      />
    </button>
  )
}
```

Pixel Heading is a React heading component for shadcn/ui using Geist pixel fonts. Use it for landing page hero titles, section headers, or retro and developer-themed branding.

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/pixel-heading-character
```

### Manual

**Install the `geist` font package.**

```bash
npm install geist
```

**Copy and paste the following code into your project.**

````tsx title="pixel-heading-character.tsx"
/**
 * @module PixelHeading (Character variant)
 *
 * Per-character pixel-font heading with four animation modes.
 *
 * Setup — Geist Pixel Fonts with Tailwind CSS
 * =============================================
 *
 * All Geist fonts can be used through CSS variables:
 *
 *   GeistSans:          --font-geist-sans
 *   GeistMono:          --font-geist-mono
 *   GeistPixelSquare:   --font-geist-pixel-square
 *   GeistPixelGrid:     --font-geist-pixel-grid
 *   GeistPixelCircle:   --font-geist-pixel-circle
 *   GeistPixelTriangle: --font-geist-pixel-triangle
 *   GeistPixelLine:     --font-geist-pixel-line
 *
 * 1. Register the font variables in app/layout.js:
 *
 *   ```js
 *   import { GeistSans } from "geist/font/sans";
 *   import { GeistMono } from "geist/font/mono";
 *   import { GeistPixelSquare } from "geist/font/pixel";
 *
 *   export default function RootLayout({ children }) {
 *     return (
 *       <html
 *         lang="en"
 *         className={`${GeistSans.variable} ${GeistMono.variable} ${GeistPixelSquare.variable}`}
 *       >
 *         <body>{children}</body>
 *       </html>
 *     );
 *   }
 *   ```
 *
 * 2. Map the CSS variables in your Tailwind CSS v4 theme (tailwind.css):
 *
 *   ```css
 *   @theme {
 *     --font-sans: var(--font-geist-sans);
 *     --font-mono: var(--font-geist-mono);
 *     --font-pixel-square: var(--font-geist-pixel-square);
 *     --font-pixel-grid: var(--font-geist-pixel-grid);
 *     --font-pixel-circle: var(--font-geist-pixel-circle);
 *     --font-pixel-triangle: var(--font-geist-pixel-triangle);
 *     --font-pixel-line: var(--font-geist-pixel-line);
 *   }
 *   ```
 *
 * Once configured, the `font-pixel-*` utility classes used by this
 * component will resolve correctly.
 */

"use client"

import { useCallback, useEffect, useMemo, useRef, useState } from "react"

import { cn } from "@/lib/utils"

/* ─── Constants ─── */

const PIXEL_FONTS = [
  "font-pixel-square",
  "font-pixel-grid",
  "font-pixel-circle",
  "font-pixel-triangle",
  "font-pixel-line",
] as const

const FONT_LABELS = ["Square", "Grid", "Circle", "Triangle", "Line"] as const
const FONT_COUNT = PIXEL_FONTS.length

/** Map short name → Tailwind class for the prefix font. */
const PREFIX_FONT_MAP: Record<string, string> = {
  square: "font-pixel-square",
  grid: "font-pixel-grid",
  circle: "font-pixel-circle",
  triangle: "font-pixel-triangle",
  line: "font-pixel-line",
}

/** Map short name → Tailwind class for isolated (non-pixel) characters. */
const ISOLATE_FONT_MAP: Record<string, string> = {
  sans: "font-sans",
  mono: "font-mono",
}

function resolveIsolateFont(value: string): string {
  return ISOLATE_FONT_MAP[value] ?? value
}

/** Golden ratio — maximises spacing between identical values in a sequence. */
const PHI = (1 + Math.sqrt(5)) / 2

/** Internal tick rate in ms — drives the stagger resolution. */
const TICK_MS = 50

/* ─── Distribution algorithms ─── */

/**
 * Golden-ratio–based index.
 * Maps a sequential position to a font index such that
 * adjacent positions almost never share the same font.
 */
function goldenBase(index: number): number {
  return Math.floor((index * PHI * FONT_COUNT) % FONT_COUNT)
}

/**
 * Deterministic pseudo-random via Knuth multiplicative hash.
 * Produces a uniform-ish distribution across FONT_COUNT for any (tick, index) pair.
 */
function pseudoRandom(tick: number, index: number): number {
  return ((tick * 2654435761 + index * 340573321) >>> 0) % FONT_COUNT
}

/* ─── Helpers ─── */

/** Recursively extract text content from React children. */
function extractText(children: React.ReactNode): string {
  if (typeof children === "string") return children
  if (typeof children === "number") return String(children)
  if (Array.isArray(children)) return children.map(extractText).join("")
  if (
    children !== null &&
    children !== undefined &&
    typeof children === "object" &&
    "props" in children
  ) {
    return extractText(
      (children as React.ReactElement<{ children?: React.ReactNode }>).props
        .children
    )
  }
  return ""
}

/* ─── Types ─── */

/**
 * Animation mode for per-character font distribution.
 *
 * | Mode       | At rest                         | On hover                                           |
 * |------------|---------------------------------|----------------------------------------------------|
 * | `uniform`  | Single font (original behavior) | Cycles one font for all characters                 |
 * | `multi`    | Golden-ratio distribution       | Staggered cascade — each char cycles independently |
 * | `wave`     | Position-based gradient         | Fonts flow left→right in a continuous wave         |
 * | `random`   | Golden-ratio distribution       | Each character scrambles independently              |
 */
export type PixelHeadingMode = "uniform" | "multi" | "wave" | "random"

/* ─── Props ─── */

export interface PixelHeadingProps extends React.ComponentProps<"h1"> {
  /**
   * The heading level to render.
   * @default "h1"
   */
  as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
  /**
   * Interval in ms between font changes per character.
   * @default 150
   */
  cycleInterval?: number
  /**
   * Initial font index (0–4). Only meaningful in `uniform` mode.
   * @default 0
   */
  defaultFontIndex?: number
  /**
   * Callback fired when the active font changes (uniform mode only).
   */
  onFontIndexChange?: (index: number) => void
  /**
   * Whether to show the label beneath the heading.
   * @default true
   */
  showLabel?: boolean
  /**
   * Controls how fonts are distributed across characters.
   *
   * - `"uniform"` — all characters share one font; cycles on hover (original)
   * - `"multi"`   — golden-ratio distribution; staggered cascade on hover
   * - `"wave"`    — fonts flow left-to-right in a continuous wave
   * - `"random"`  — each character scrambles independently per tick
   *
   * @default "multi"
   */
  mode?: PixelHeadingMode
  /**
   * Milliseconds of delay between each successive character's animation start.
   * Creates a left→right cascade / ripple effect.
   * Only applies in `multi`, `wave`, and `random` modes.
   * @default 50
   */
  staggerDelay?: number
  /**
   * When true the animation runs automatically on mount —
   * no hover or focus required. Hover/focus still work to
   * restart the cascade.
   * @default false
   */
  autoPlay?: boolean
  /**
   * Static text rendered before the animated children.
   * Does not animate — stays locked to the font set by `prefixFont`.
   * A trailing space is added automatically.
   */
  prefix?: string
  /**
   * Which pixel font to apply to the `prefix` text.
   * Set to `"none"` to use the inherited font (e.g. font-sans).
   * @default "none"
   */
  prefixFont?: "square" | "grid" | "circle" | "triangle" | "line" | "none"
  /**
   * Map of characters to exclude from pixel-font animation.
   * Keys are single characters (case-sensitive).
   * Values are font short-names ("sans" | "mono") or arbitrary
   * Tailwind font class names (e.g. "font-serif").
   *
   * Isolated characters always render in their assigned font,
   * even during hover/auto-play animation.
   */
  isolate?: Record<string, string>
}

/* ─── Component ─── */

/**
 * Interactive heading where **each character** can display a different
 * pixel font. On hover the fonts animate — the distribution algorithm
 * and cascade timing are controlled via the `mode` and `staggerDelay` props.
 *
 * @example
 * ```tsx
 * <PixelHeading mode="multi" className="text-7xl">
 *   Shadcn, expanded
 * </PixelHeading>
 * ```
 *
 * @example
 * ```tsx
 * <PixelHeading mode="wave" cycleInterval={100} staggerDelay={30}>
 *   Wave effect
 * </PixelHeading>
 * ```
 *
 * @example
 * ```tsx
 * <PixelHeading mode="multi" autoPlay>
 *   Runs on mount, no hover needed
 * </PixelHeading>
 * ```
 *
 * @example
 * ```tsx
 * <PixelHeading prefix="Shadcn," prefixFont="grid" mode="wave" autoPlay>
 *   expanded
 * </PixelHeading>
 * ```
 *
 * @example
 * ```tsx
 * <PixelHeading
 *   prefix="Shadcn,"
 *   prefixFont="grid"
 *   isolate={{ x: "sans", h: "mono" }}
 *   mode="wave"
 *   autoPlay
 * >
 *   expanded
 * </PixelHeading>
 * ```
 *
 * @example
 * ```tsx
 * <PixelHeading mode="uniform" className="text-6xl">
 *   Classic single-font cycle
 * </PixelHeading>
 * ```
 */
export function PixelHeading({
  children,
  as: Tag = "h1",
  className,
  cycleInterval = 150,
  defaultFontIndex = 0,
  onFontIndexChange,
  showLabel = false,
  mode = "multi",
  staggerDelay = 50,
  autoPlay = false,
  prefix,
  prefixFont = "none",
  isolate,
  onMouseEnter,
  onMouseLeave,
  onFocus,
  onBlur,
  onKeyDown,
  ...props
}: PixelHeadingProps) {
  const text = useMemo(() => extractText(children), [children])

  const [msElapsed, setMsElapsed] = useState(0)
  const [isActive, setIsActive] = useState(false)
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
  const prevUniformIndex = useRef(defaultFontIndex)

  /* ── Cleanup ── */
  useEffect(() => {
    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current)
    }
  }, [])

  /* ── Auto-play: start cycling on mount ── */
  useEffect(() => {
    if (!autoPlay) return
    // Kick off the interval immediately
    setIsActive(true)
    setMsElapsed(0)
    intervalRef.current = setInterval(() => {
      setMsElapsed((prev) => prev + TICK_MS)
    }, TICK_MS)

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current)
        intervalRef.current = null
      }
    }
  }, [autoPlay])

  /* ── Compute per-character font indices ── */
  const charFonts = useMemo(() => {
    const fonts: number[] = []
    let vi = 0 // visible-character index (skips spaces)

    for (let i = 0; i < text.length; i++) {
      if (text[i] === " ") {
        fonts.push(-1)
        continue
      }

      switch (mode) {
        case "uniform": {
          const cycles = Math.floor(msElapsed / cycleInterval)
          const idx = (defaultFontIndex + cycles) % FONT_COUNT
          fonts.push(idx)
          break
        }
        case "multi": {
          const base = goldenBase(vi)
          const charMs = Math.max(0, msElapsed - vi * staggerDelay)
          const cycles = Math.floor(charMs / cycleInterval)
          fonts.push((base + cycles) % FONT_COUNT)
          break
        }
        case "wave": {
          const charMs = Math.max(0, msElapsed - vi * staggerDelay)
          const cycles = Math.floor(charMs / cycleInterval)
          fonts.push((vi + cycles) % FONT_COUNT)
          break
        }
        case "random": {
          const charMs = Math.max(0, msElapsed - vi * staggerDelay)
          const cycles = Math.floor(charMs / cycleInterval)
          fonts.push(cycles > 0 ? pseudoRandom(cycles, vi) : goldenBase(vi))
          break
        }
      }
      vi++
    }
    return fonts
  }, [text, mode, msElapsed, cycleInterval, staggerDelay, defaultFontIndex])

  /* ── Fire callback for uniform mode ── */
  useEffect(() => {
    if (mode !== "uniform") return
    const idx = charFonts.find((f) => f !== -1) ?? defaultFontIndex
    if (idx !== prevUniformIndex.current) {
      prevUniformIndex.current = idx
      onFontIndexChange?.(idx)
    }
  }, [charFonts, mode, defaultFontIndex, onFontIndexChange])

  /* ── Label text ── */
  const activeLabel = useMemo(() => {
    if (mode === "uniform") {
      const idx = charFonts.find((f) => f !== -1) ?? 0
      return FONT_LABELS[idx]
    }
    const modeLabels: Record<PixelHeadingMode, string> = {
      uniform: "",
      multi: "Multi",
      wave: "Wave",
      random: "Random",
    }
    return modeLabels[mode]
  }, [mode, charFonts])

  /* ── Start / stop cycling ── */
  const startCycling = useCallback(() => {
    // Clear any existing interval first to avoid stacking
    if (intervalRef.current) {
      clearInterval(intervalRef.current)
      intervalRef.current = null
    }
    setIsActive(true)
    setMsElapsed(0)
    intervalRef.current = setInterval(() => {
      setMsElapsed((prev) => prev + TICK_MS)
    }, TICK_MS)
  }, [])

  const stopCycling = useCallback(() => {
    if (autoPlay) {
      // Auto-play keeps running — just restart the cascade
      setIsActive(true)
      return
    }
    setIsActive(false)
    if (intervalRef.current) {
      clearInterval(intervalRef.current)
      intervalRef.current = null
    }
  }, [autoPlay])

  /* ── Event handlers ── */
  const handleMouseEnter = useCallback(
    (e: React.MouseEvent<HTMLHeadingElement>) => {
      startCycling()
      onMouseEnter?.(e)
    },
    [startCycling, onMouseEnter]
  )

  const handleMouseLeave = useCallback(
    (e: React.MouseEvent<HTMLHeadingElement>) => {
      stopCycling()
      onMouseLeave?.(e)
    },
    [stopCycling, onMouseLeave]
  )

  const handleFocus = useCallback(
    (e: React.FocusEvent<HTMLHeadingElement>) => {
      startCycling()
      onFocus?.(e)
    },
    [startCycling, onFocus]
  )

  const handleBlur = useCallback(
    (e: React.FocusEvent<HTMLHeadingElement>) => {
      stopCycling()
      onBlur?.(e)
    },
    [stopCycling, onBlur]
  )

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLHeadingElement>) => {
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault()
        setMsElapsed((prev) => prev + cycleInterval)
      }
      onKeyDown?.(e)
    },
    [cycleInterval, onKeyDown]
  )

  /* ── Uniform font index (for class on the Tag itself) ── */
  const uniformIdx =
    mode === "uniform"
      ? (charFonts.find((f) => f !== -1) ?? defaultFontIndex)
      : 0

  return (
    <div
      data-slot="pixel-heading"
      className="inline-flex flex-col items-start gap-2"
    >
      <Tag
        data-state={isActive ? "active" : "idle"}
        data-mode={mode}
        aria-label={prefix ? `${prefix} ${text}` : text}
        tabIndex={0}
        className={cn(
          "cursor-default select-none",
          "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
          mode === "uniform" && PIXEL_FONTS[uniformIdx],
          className
        )}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onFocus={handleFocus}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        {...props}
      >
        {/* ── Static prefix ── */}
        {prefix && (
          <>
            {isolate ? (
              prefix.split("").map((char, i) => (
                <span
                  // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence
                  key={`p${i}`}
                  className={cn(
                    prefixFont !== "none"
                      ? PREFIX_FONT_MAP[prefixFont]
                      : undefined,
                    isolate[char]
                      ? resolveIsolateFont(isolate[char])
                      : undefined
                  )}
                  aria-hidden
                >
                  {char}
                </span>
              ))
            ) : (
              <span
                className={
                  prefixFont !== "none"
                    ? PREFIX_FONT_MAP[prefixFont]
                    : undefined
                }
                aria-hidden
              >
                {prefix}
              </span>
            )}
            <span> </span>
          </>
        )}

        {/* ── Animated characters ── */}
        {mode === "uniform"
          ? children
          : text.split("").map((char, i) =>
              char === " " ? (
                // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence
                <span key={i}> </span>
              ) : isolate?.[char] ? (
                <span
                  // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence
                  key={i}
                  className={resolveIsolateFont(isolate[char])}
                  aria-hidden
                >
                  {char}
                </span>
              ) : (
                <span
                  // biome-ignore lint/suspicious/noArrayIndexKey: stable character sequence
                  key={i}
                  className={PIXEL_FONTS[charFonts[i]]}
                  aria-hidden
                >
                  {char}
                </span>
              )
            )}
      </Tag>
      {showLabel && (
        <output
          data-slot="pixel-heading-label"
          aria-live="polite"
          className={cn(
            "text-muted-foreground text-xs tracking-widest uppercase transition-opacity duration-200",
            isActive || autoPlay ? "opacity-100" : "opacity-0"
          )}
        >
          {activeLabel}
        </output>
      )}
    </div>
  )
}
````

**Update the import paths to match your project setup.**

## Font Setup

This component requires the **Geist Pixel fonts**. Follow these steps to configure them in your project.

### 1. Register font variables in your root layout

Import the pixel font variants from `geist/font/pixel` and apply their CSS variable classes to `<body>`:

```tsx title="app/layout.tsx"
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import {
  GeistPixelSquare,
  GeistPixelGrid,
  GeistPixelCircle,
  GeistPixelTriangle,
  GeistPixelLine,
} from "geist/font/pixel";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body
        className={`${GeistSans.variable} ${GeistMono.variable} ${GeistPixelSquare.variable} ${GeistPixelGrid.variable} ${GeistPixelCircle.variable} ${GeistPixelTriangle.variable} ${GeistPixelLine.variable}`}
      >
        {children}
      </body>
    </html>
  );
}
```

Each import exposes a `.variable` property that injects a CSS custom property:

| Import               | CSS Variable                  |
| -------------------- | ----------------------------- |
| `GeistSans`          | `--font-geist-sans`           |
| `GeistMono`          | `--font-geist-mono`           |
| `GeistPixelSquare`   | `--font-geist-pixel-square`   |
| `GeistPixelGrid`     | `--font-geist-pixel-grid`     |
| `GeistPixelCircle`   | `--font-geist-pixel-circle`   |
| `GeistPixelTriangle` | `--font-geist-pixel-triangle` |
| `GeistPixelLine`     | `--font-geist-pixel-line`     |

### 2. Map CSS variables in your Tailwind CSS theme

Add the font mappings to your global CSS file so the `font-pixel-*` utility classes resolve correctly:

```css title="globals.css (Tailwind v4)"
@theme {
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
  --font-pixel-square: var(--font-geist-pixel-square);
  --font-pixel-grid: var(--font-geist-pixel-grid);
  --font-pixel-circle: var(--font-geist-pixel-circle);
  --font-pixel-triangle: var(--font-geist-pixel-triangle);
  --font-pixel-line: var(--font-geist-pixel-line);
}
```

For **Tailwind v3**, use the `extend` key in `tailwind.config.ts`:

```ts title="tailwind.config.ts (Tailwind v3)"
export default {
  theme: {
    extend: {
      fontFamily: {
        sans: ["var(--font-geist-sans)"],
        mono: ["var(--font-geist-mono)"],
        "pixel-square": ["var(--font-geist-pixel-square)"],
        "pixel-grid": ["var(--font-geist-pixel-grid)"],
        "pixel-circle": ["var(--font-geist-pixel-circle)"],
        "pixel-triangle": ["var(--font-geist-pixel-triangle)"],
        "pixel-line": ["var(--font-geist-pixel-line)"],
      },
    },
  },
};
```

## Usage

```tsx
import { PixelHeading } from "@/components/ui/pixel-heading-character";
```

### Basic

```tsx
<PixelHeading className="text-6xl">Hello World</PixelHeading>
```

### Animation Modes

The `mode` prop controls how fonts are distributed and animated across characters:

```tsx
{
  /* All characters share one font — cycles on hover */
}
<PixelHeading mode="uniform" className="text-6xl">
  Uniform
</PixelHeading>;

{
  /* Golden-ratio distribution — staggered cascade on hover */
}
<PixelHeading mode="multi" className="text-6xl">
  Multi
</PixelHeading>;

{
  /* Fonts flow left-to-right in a continuous wave */
}
<PixelHeading mode="wave" className="text-6xl">
  Wave
</PixelHeading>;

{
  /* Each character scrambles independently */
}
<PixelHeading mode="random" className="text-6xl">
  Random
</PixelHeading>;
```

### Auto Play

Run the animation automatically on mount — no hover required:

```tsx
<PixelHeading mode="wave" autoPlay className="text-7xl">
  Always moving
</PixelHeading>
```

### Prefix Text

Render static text before the animated children. The prefix stays locked to the specified font:

```tsx
<PixelHeading
  prefix="Shadcn,"
  prefixFont="grid"
  mode="wave"
  autoPlay
  className="text-6xl"
>
  expanded
</PixelHeading>
```

### Isolate Characters

Exclude specific characters from the pixel-font animation and lock them to a different font:

```tsx
<PixelHeading
  isolate={{ x: "sans", h: "mono" }}
  mode="multi"
  autoPlay
  className="text-6xl"
>
  pixel text
</PixelHeading>
```

### Timing Control

Fine-tune the animation speed and cascade timing:

```tsx
<PixelHeading
  mode="wave"
  cycleInterval={80}
  staggerDelay={30}
  autoPlay
  className="text-6xl"
>
  Fast wave
</PixelHeading>
```

### Show Font Label

Display a label beneath the heading indicating the current mode or active font:

```tsx
<PixelHeading mode="uniform" showLabel className="text-6xl">
  With label
</PixelHeading>
```

### Custom Heading Level

Render as any heading element (`h1`–`h6`):

```tsx
<PixelHeading as="h3" mode="multi" className="text-4xl">
  H3 heading
</PixelHeading>
```

## API Reference

### PixelHeading Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `as` | `"h1" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6"` | `"h1"` | HTML heading level to render |
| `mode` | `"uniform" \| "multi" \| "wave" \| "random"` | `"multi"` | Controls how fonts are distributed across characters |
| `autoPlay` | `boolean` | `false` | Run animation on mount without hover/focus |
| `cycleInterval` | `number` | `150` | Interval in ms between font changes per character |
| `staggerDelay` | `number` | `50` | Delay in ms between each successive character's animation start |
| `defaultFontIndex` | `number` | `0` | Initial font index (0–4). Only meaningful in `uniform` mode |
| `showLabel` | `boolean` | `false` | Show the active mode/font label beneath the heading |
| `prefix` | `string` | — | Static text rendered before the animated children |
| `prefixFont` | `"square" \| "grid" \| "circle" \| "triangle" \| "line" \| "none"` | `"none"` | Which pixel font to use for the prefix (or `"none"` for inherited) |
| `isolate` | `Record<string, string>` | — | Map of characters to exclude from animation. Keys are characters, values are font names (`"sans"`, `"mono"`, etc.) |
| `onFontIndexChange` | `(index: number) => void` | — | Callback fired when the active font changes (uniform mode only) |
| `className` | `string` | — | Additional CSS classes applied to the heading element |

### PixelHeadingMode Type

```tsx
type PixelHeadingMode = "uniform" | "multi" | "wave" | "random";
```

| Mode | At Rest | On Hover / Auto-Play |
| --- | --- | --- |
| `uniform` | Single font for all chars | Cycles one font across all characters |
| `multi` | Golden-ratio distribution | Staggered cascade — each char cycles independently |
| `wave` | Position-based gradient | Fonts flow left→right in a continuous wave |
| `random` | Golden-ratio distribution | Each character scrambles independently |

## Features

- **Four animation modes** — uniform, multi, wave, and random
- **Per-character control** — each character animates independently with configurable stagger
- **Auto-play** — animation runs on mount, no user interaction required
- **Prefix support** — static text before animated content with separate font control
- **Character isolation** — exclude specific characters from animation
- **Accessible** — proper `aria-label`, focus management, and keyboard support (Enter/Space to step)
- **Zero dependencies** — only requires Geist fonts (no motion library needed)
- **Composable** — renders as any heading level with full className support

## Notes

- The component uses five Geist pixel font variants: Square, Grid, Circle, Triangle, and Line
- The golden-ratio distribution algorithm ensures adjacent characters almost never share the same font
- Hover/focus starts the animation cycle; leaving stops it (unless `autoPlay` is enabled)
- Keyboard users can step through fonts one tick at a time with Enter or Space
- The internal tick rate is 50ms — `staggerDelay` and `cycleInterval` control the perceived speed
