# Pixel Heading (Word)

> Pixel font heading for React and shadcn/ui that swaps whole words between Geist pixel fonts on hover, with no animation library.

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

## Example

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

import { useState } from "react"

import { PixelHeading } from "@/components/ui/pixel-heading-word"

/* ─── Constants ─── */

const PIXEL_FONTS = ["square", "grid", "circle", "triangle", "line"] as const
type PixelFont = (typeof PIXEL_FONTS)[number]

const HEADING_LEVELS = ["h1", "h2", "h3", "h4", "h5", "h6"] as const

/* ─── Demo ─── */

export default function PixelHeadingWordDemo() {
  const [text, setText] = useState("Pixel Fonts")
  const [initialFont, setInitialFont] = useState<PixelFont>("square")
  const [hoverFont, setHoverFont] = useState<PixelFont | "cycle">("triangle")

  const [showLabel, setShowLabel] = useState(true)
  const [headingLevel, setHeadingLevel] =
    useState<(typeof HEADING_LEVELS)[number]>("h1")

  const isSwapMode = hoverFont !== "cycle"

  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
          as={headingLevel}
          initialFont={initialFont}
          hoverFont={isSwapMode ? (hoverFont as PixelFont) : undefined}
          showLabel={showLabel}
          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>

        {/* Initial Font */}
        <ControlGroup label="Initial Font">
          <div className="flex flex-wrap gap-1.5">
            {PIXEL_FONTS.map((f) => (
              <button
                type="button"
                key={f}
                onClick={() => setInitialFont(f)}
                className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
                  initialFont === f
                    ? "bg-foreground text-background"
                    : "bg-muted text-muted-foreground hover:bg-muted/80"
                }`}
              >
                {f}
              </button>
            ))}
          </div>
        </ControlGroup>

        {/* Hover Font / Mode */}
        <ControlGroup label="Hover Behavior">
          <div className="flex flex-wrap gap-1.5">
            <button
              type="button"
              onClick={() => setHoverFont("cycle")}
              className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
                hoverFont === "cycle"
                  ? "bg-foreground text-background"
                  : "bg-muted text-muted-foreground hover:bg-muted/80"
              }`}
            >
              cycle all
            </button>
            {PIXEL_FONTS.map((f) => (
              <button
                type="button"
                key={f}
                onClick={() => setHoverFont(f)}
                className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
                  hoverFont === f
                    ? "bg-foreground text-background"
                    : "bg-muted text-muted-foreground hover:bg-muted/80"
                }`}
              >
                {f}
              </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>

        {/* Show Label */}
        <ControlGroup label="Show Label">
          <Toggle checked={showLabel} onChange={setShowLabel} />
        </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 (Word) is a React heading component for shadcn/ui with no animation library dependency. Use it for hero titles, section headings, or logo-style wordmarks on developer and product sites.

## Installation

### CLI

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

### Manual

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

```bash
npm install geist
```

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

````tsx title="pixel-heading-word.tsx"
/**
 * @module PixelHeading
 *
 * 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, useRef, useState } from "react"

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

type PixelFont = "square" | "grid" | "circle" | "triangle" | "line"

const PIXEL_FONT_MAP: Record<PixelFont, string> = {
  square: "font-pixel-square",
  grid: "font-pixel-grid",
  circle: "font-pixel-circle",
  triangle: "font-pixel-triangle",
  line: "font-pixel-line",
}

const PIXEL_FONTS = Object.values(PIXEL_FONT_MAP)
const PIXEL_FONT_KEYS = Object.keys(PIXEL_FONT_MAP) as PixelFont[]

/**
 * Props for the PixelHeading component.
 *
 * Extends native heading element attributes so all standard HTML
 * props (id, aria-*, data-*, event handlers) are forwarded.
 */
export interface PixelHeadingProps extends React.ComponentProps<"h1"> {
  /**
   * The heading level to render.
   * @default "h1"
   */
  as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
  /**
   * The resting pixel font displayed by default.
   * @default "square"
   */
  initialFont?: PixelFont
  /**
   * The pixel font to show on hover / focus.
   * When set the component swaps between `initialFont` and `hoverFont`
   * instead of cycling through every font.
   */
  hoverFont?: PixelFont
  /**
   * Interval in milliseconds between font cycles on hover.
   * Only used when `hoverFont` is **not** set (cycling mode).
   * @default 300
   */
  cycleInterval?: number
  /**
   * Initial font index (0–4) for the starting pixel font.
   * Ignored when `initialFont` is set.
   * @default 0
   */
  defaultFontIndex?: number
  /**
   * Callback fired when the active font index changes.
   */
  onFontIndexChange?: (index: number) => void
  /**
   * Whether to show the font name label beneath the heading.
   * @default true
   */
  showLabel?: boolean
  /**
   * Disable all hover / focus interactions.
   * The heading stays locked to `initialFont` (or `defaultFontIndex`).
   * @default false
   */
  disableHover?: boolean
  /**
   * Disable the auto-cycling interval in cycle mode.
   * Swap mode (`hoverFont`) still works unless `disableHover` is also set.
   * @default false
   */
  disableCycling?: boolean
}

/**
 * Interactive heading that swaps or cycles through pixel font styles on hover.
 *
 * **Swap mode** — set `initialFont` and `hoverFont` to swap between two
 * specific fonts on hover:
 *
 * @example
 * <PixelHeading initialFont="square" hoverFont="circle" className="text-6xl">
 *   Swap on hover
 * </PixelHeading>
 *
 * **Cycle mode** — omit `hoverFont` to cycle through every pixel font on
 * hover (the original behavior):
 *
 * @example
 * <PixelHeading
 *   as="h2"
 *   cycleInterval={200}
 *   onFontIndexChange={(i) => console.log(i)}
 * >
 *   Cycle on hover
 * </PixelHeading>
 */
export function PixelHeading({
  children,
  as: Tag = "h1",
  className,
  initialFont,
  hoverFont,
  cycleInterval = 300,
  defaultFontIndex = 0,
  onFontIndexChange,
  showLabel = false,
  disableHover = false,
  disableCycling = false,
  onMouseEnter,
  onMouseLeave,
  onFocus,
  onBlur,
  onKeyDown,
  ...props
}: PixelHeadingProps) {
  /* ------------------------------------------------------------------ */
  /* Resolve the starting index from `initialFont` or `defaultFontIndex` */
  /* ------------------------------------------------------------------ */
  const resolvedDefaultIndex = initialFont
    ? PIXEL_FONT_KEYS.indexOf(initialFont)
    : defaultFontIndex

  const hoverIndex = hoverFont ? PIXEL_FONT_KEYS.indexOf(hoverFont) : null
  const isSwapMode = hoverIndex !== null

  const [fontIndex, setFontIndex] = useState(resolvedDefaultIndex)
  const [isActive, setIsActive] = useState(false)
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)

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

  /* ---- Cycling helpers (cycle mode only) ---- */
  const advanceFont = useCallback(() => {
    setFontIndex((prev) => {
      const next = (prev + 1) % PIXEL_FONTS.length
      onFontIndexChange?.(next)
      return next
    })
  }, [onFontIndexChange])

  const startCycling = useCallback(() => {
    setIsActive(true)
    intervalRef.current = setInterval(advanceFont, cycleInterval)
  }, [advanceFont, cycleInterval])

  const stopCycling = useCallback(() => {
    setIsActive(false)
    if (intervalRef.current) {
      clearInterval(intervalRef.current)
      intervalRef.current = null
    }
  }, [])

  /* ---- Swap helpers ---- */
  const swapToHover = useCallback(() => {
    if (hoverIndex === null) return
    setIsActive(true)
    setFontIndex(hoverIndex)
    onFontIndexChange?.(hoverIndex)
  }, [hoverIndex, onFontIndexChange])

  const swapToInitial = useCallback(() => {
    setIsActive(false)
    setFontIndex(resolvedDefaultIndex)
    onFontIndexChange?.(resolvedDefaultIndex)
  }, [resolvedDefaultIndex, onFontIndexChange])

  /* ---- Event handlers ---- */
  const handleMouseEnter = useCallback(
    (e: React.MouseEvent<HTMLHeadingElement>) => {
      if (!disableHover) {
        if (isSwapMode) {
          swapToHover()
        } else if (!disableCycling) {
          startCycling()
        }
      }
      onMouseEnter?.(e)
    },
    [
      disableHover,
      disableCycling,
      isSwapMode,
      swapToHover,
      startCycling,
      onMouseEnter,
    ]
  )

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

  const handleFocus = useCallback(
    (e: React.FocusEvent<HTMLHeadingElement>) => {
      if (!disableHover) {
        isSwapMode ? swapToHover() : setIsActive(true)
      }
      onFocus?.(e)
    },
    [disableHover, isSwapMode, swapToHover, onFocus]
  )

  const handleBlur = useCallback(
    (e: React.FocusEvent<HTMLHeadingElement>) => {
      if (!disableHover) {
        isSwapMode ? swapToInitial() : setIsActive(false)
      }
      onBlur?.(e)
    },
    [disableHover, isSwapMode, swapToInitial, onBlur]
  )

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLHeadingElement>) => {
      if (!disableHover && !disableCycling) {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault()
          if (!isSwapMode) advanceFont()
        }
      }
      onKeyDown?.(e)
    },
    [disableHover, disableCycling, isSwapMode, advanceFont, onKeyDown]
  )

  const currentFontLabel = PIXEL_FONT_KEYS[fontIndex]

  return (
    <div
      data-slot="pixel-heading"
      className="inline-flex flex-col items-start gap-2"
    >
      <Tag
        data-state={isActive ? "active" : "idle"}
        data-font={currentFontLabel}
        tabIndex={0}
        className={cn(
          "cursor-default transition-all duration-150 select-none",
          "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
          PIXEL_FONTS[fontIndex],
          className
        )}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onFocus={handleFocus}
        onBlur={handleBlur}
        onKeyDown={handleKeyDown}
        {...props}
      >
        {children}
      </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 ? "opacity-100" : "opacity-0"
          )}
        >
          {currentFontLabel}
        </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-word";
```

### Swap Mode

Set `initialFont` and `hoverFont` to swap between two specific fonts on hover:

```tsx
<PixelHeading initialFont="square" hoverFont="circle" className="text-6xl">
  Swap on hover
</PixelHeading>
```

### Cycle Mode

Omit `hoverFont` to cycle through every pixel font on hover:

```tsx
<PixelHeading initialFont="square" className="text-6xl">
  Cycle on hover
</PixelHeading>
```

### Custom Cycle Speed

Control how fast fonts cycle in cycle mode:

```tsx
<PixelHeading cycleInterval={150} className="text-6xl">
  Fast cycle
</PixelHeading>
```

### Show Font Label

Display the current font name beneath the heading:

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

### Custom Heading Level

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

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

### Font Change Callback

Listen for font index changes:

```tsx
<PixelHeading
  onFontIndexChange={(index) => console.log("Font index:", index)}
  className="text-6xl"
>
  With callback
</PixelHeading>
```

## API Reference

### PixelHeading Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `as` | `"h1" \| "h2" \| "h3" \| "h4" \| "h5" \| "h6"` | `"h1"` | HTML heading level to render |
| `initialFont` | `"square" \| "grid" \| "circle" \| "triangle" \| "line"` | `"square"` | The resting pixel font displayed by default |
| `hoverFont` | `"square" \| "grid" \| "circle" \| "triangle" \| "line"` | — | Font to show on hover. When set, swaps instead of cycling |
| `cycleInterval` | `number` | `300` | Interval in ms between font cycles on hover (cycle mode only) |
| `defaultFontIndex` | `number` | `0` | Initial font index (0–4). Ignored when `initialFont` is set |
| `showLabel` | `boolean` | `false` | Show the active font name label beneath the heading |
| `onFontIndexChange` | `(index: number) => void` | — | Callback fired when the active font changes |
| `className` | `string` | — | Additional CSS classes applied to the heading element |

### Swap Mode vs Cycle Mode

| Behavior | Swap Mode | Cycle Mode |
| --- | --- | --- |
| Configuration | Set both `initialFont` and `hoverFont` | Set `initialFont` only (omit `hoverFont`) |
| On hover | Instantly swaps to `hoverFont` | Cycles through all 5 fonts at `cycleInterval` speed |
| On leave | Returns to `initialFont` | Stops cycling, stays on last font |
| Keyboard | No keyboard step (instant swap) | Enter/Space advances one font |

### Available Pixel Fonts

| Font Name  | Tailwind Class        | CSS Variable                  |
| ---------- | --------------------- | ----------------------------- |
| `square`   | `font-pixel-square`   | `--font-geist-pixel-square`   |
| `grid`     | `font-pixel-grid`     | `--font-geist-pixel-grid`     |
| `circle`   | `font-pixel-circle`   | `--font-geist-pixel-circle`   |
| `triangle` | `font-pixel-triangle` | `--font-geist-pixel-triangle` |
| `line`     | `font-pixel-line`     | `--font-geist-pixel-line`     |

## Features

- **Two interaction modes** — swap between two fonts or cycle through all five
- **Whole-word animation** — the entire heading changes font at once for a bold effect
- **Accessible** — keyboard support (Enter/Space to step), focus management, and `aria-live` label
- **Zero animation dependencies** — uses CSS transitions only, no motion library needed
- **Composable** — renders as any heading level with full className support

## Pixel Heading (Character) vs Pixel Heading (Word)

| Feature | Character variant | Word variant |
| --- | --- | --- |
| Animation granularity | Each character animates independently | Whole heading changes at once |
| Modes | uniform, multi, wave, random | swap, cycle |
| Auto-play | Yes | No (hover/focus only) |
| Prefix support | Yes | No |
| Character isolation | Yes | No |
| Stagger control | Yes | N/A |
| Best for | Hero text, large display headings | Buttons, labels, smaller headings |

## Notes

- The component uses five Geist pixel font variants: Square, Grid, Circle, Triangle, and Line
- In swap mode, the transition between fonts uses a CSS `transition-all duration-150` for smoothness
- In cycle mode, fonts advance every `cycleInterval` ms while the heading is hovered or focused
- Keyboard users can step through fonts one at a time with Enter or Space (cycle mode only)
- The `showLabel` feature uses an `<output>` element with `aria-live="polite"` for screen reader announcements
