# Billet — Animated Loader for React

> Content-shaped skeleton placeholder — matte graphite blocks crossed by a single cool-white light pass (heat-pass timing, not a conveyor). Card / text / circle / rect presets compose to prefigure any layout; @property-driven, zero dependencies, role=status + aria-busy, gentle opacity breathe under reduced motion.

- Page: https://crucibleui.com/components/loaders/billet
- Category: [loaders](https://crucibleui.com/components/loaders)
- Tier: free (full source)
- Engine: css

## Install

```bash
npx shadcn@latest add @crucible/billet
```

Registry JSON: https://crucibleui.com/r/billet.json

Import from `@/components/crucible/billet`.

## Props (`BilletProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `shape?` | `"card" \| "text" \| "circle" \| "rect"` | `"card"` | Which content-shaped placeholder to forge. - `card` (default): an avatar + heading rows, a media rect, and a paragraph — a full content card blank. - `text`: a stack of text-line bars, the last one short. - `circle`: a single avatar disc. - `rect`: a single media/thumbnail block. Compose several to prefigure any layout. |
| `lines?` | `number` | `3` | Number of text-line bars for `text` (and the card paragraph). |
| `lastLineWidth?` | `string` | `"62%"` | Width of the final, short line — any CSS length/percentage. |
| `size?` | `number` | `48` | Diameter in px of the `circle` avatar disc. |
| `speed?` | `number` | `1` | Sweep-cycle speed multiplier. 1 = default, 2 = twice as fast. |
| `direction?` | `"left" \| "right"` | `"right"` | Direction the light pass travels. |
| `angle?` | `number` | `12` | Tilt of the light band in degrees (0 = vertical bar). |
| `radius?` | `number` | `8` | Corner radius in px for blocks and the card panel. |
| `mode?` | `"sweep" \| "pulse"` | `"sweep"` | `sweep` (default): a single cool-white light pass with matte graphite between passes. `pulse`: a gentle opacity breathe, no pass. |
| `baseColor?` | `string` | `"#1c1e24"` | Matte base tone of the placeholder blocks. Any CSS color. |
| `highlightColor?` | `string` | `"#e6ecf7"` | Peak tone of the light pass. Any CSS color. |
| `paused?` | `boolean` | `false` | Freeze the animation on a designed lit frame. |
| `label?` | `string` | `"Loading"` | Accessible label announced while content loads. |

Also accepts: `React.ComponentPropsWithoutRef<"div">`.

## Usage example

```tsx
import { Billet } from "@/components/crucible/billet";

// A list-row skeleton, composed from the circle + text presets — showing how
// Billet blocks prefigure an arbitrary layout while its rows share one pass.
function ListRows() {
  return (
    <div className="flex flex-col gap-5">
      {[0, 1, 2].map((i) => (
        <div key={i} className="flex items-center gap-3">
          <Billet shape="circle" size={38} label="Loading list" />
          <div className="min-w-0 flex-1">
            <Billet shape="text" lines={2} lastLineWidth="45%" label="Loading list" />
          </div>
        </div>
      ))}
    </div>
  );
}

const PANELS = [
  { key: "card", label: "Card", node: <Billet shape="card" label="Loading card" /> },
  { key: "rows", label: "List rows", node: <ListRows /> },
  {
    key: "text",
    label: "Paragraph",
    node: <Billet shape="text" lines={6} lastLineWidth="55%" label="Loading article" />,
  },
] as const;

export default function BilletDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center gap-8 overflow-hidden rounded-xl bg-neutral-950 p-8">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.03),transparent_70%)]"
      />

      <p className="relative z-10 max-w-md text-center text-sm text-neutral-400">
        Content-shaped placeholders while data loads — a single cool light pass
        crosses matte blocks, resting between passes.
      </p>

      <div className="relative z-10 grid w-full max-w-3xl gap-8 sm:grid-cols-3">
        {PANELS.map(({ key, label, node }) => (
          <div key={key} className="flex flex-col gap-4">
            {node}
            <span className="text-center text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-600">
              {label}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`billet.tsx`)

```tsx
import * as React from "react";

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

export interface BilletProps extends React.ComponentPropsWithoutRef<"div"> {
  /**
   * Which content-shaped placeholder to forge.
   * - `card` (default): an avatar + heading rows, a media rect, and a
   *   paragraph — a full content card blank.
   * - `text`: a stack of text-line bars, the last one short.
   * - `circle`: a single avatar disc.
   * - `rect`: a single media/thumbnail block.
   * Compose several to prefigure any layout.
   * @default "card"
   */
  shape?: "card" | "text" | "circle" | "rect";
  /** Number of text-line bars for `text` (and the card paragraph). @default 3 */
  lines?: number;
  /** Width of the final, short line — any CSS length/percentage. @default "62%" */
  lastLineWidth?: string;
  /** Diameter in px of the `circle` avatar disc. @default 48 */
  size?: number;
  /** Sweep-cycle speed multiplier. 1 = default, 2 = twice as fast. @default 1 */
  speed?: number;
  /** Direction the light pass travels. @default "right" */
  direction?: "left" | "right";
  /** Tilt of the light band in degrees (0 = vertical bar). @default 12 */
  angle?: number;
  /** Corner radius in px for blocks and the card panel. @default 8 */
  radius?: number;
  /**
   * `sweep` (default): a single cool-white light pass with matte graphite
   * between passes. `pulse`: a gentle opacity breathe, no pass.
   * @default "sweep"
   */
  mode?: "sweep" | "pulse";
  /** Matte base tone of the placeholder blocks. Any CSS color. @default "#1c1e24" */
  baseColor?: string;
  /** Peak tone of the light pass. Any CSS color. @default "#e6ecf7" */
  highlightColor?: string;
  /** Freeze the animation on a designed lit frame. @default false */
  paused?: boolean;
  /** Accessible label announced while content loads. @default "Loading" */
  label?: string;
}

// @property makes --billet-pos a real animatable <number>, so the custom
// property INTERPOLATES between keyframes (unregistered custom properties
// snap, they don't tween) — that smooth tween is the whole light pass. The
// keyframes dwell off-screen (matte) then fire a single eased pass: a heat
// pass, not a constant conveyor. Reduced motion swaps to a slow opacity
// breathe — a living resting frame, never a dead grey box. All pure CSS:
// browsers already throttle CSS animations offscreen and on hidden tabs, so
// no JS observer is needed to pause.
const STYLE = `
@property --billet-pos {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
@keyframes crucible-billet-sweep {
  0%, 44%  { --billet-pos: var(--billet-from); }
  44%      { --billet-pos: var(--billet-from); animation-timing-function: cubic-bezier(0.45, 0, 0.15, 1); }
  80%      { --billet-pos: var(--billet-to); }
  100%     { --billet-pos: var(--billet-to); }
}
@keyframes crucible-billet-pulse {
  0%, 100% { opacity: 0.5; }
  50%      { opacity: 1; }
}
[data-crucible="billet"] .billet-block {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  background-color: var(--billet-base);
  border-radius: var(--billet-radius);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.035), inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
[data-crucible="billet"] .billet-shine {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: linear-gradient(
    var(--billet-grad-angle),
    transparent 34%,
    color-mix(in oklab, var(--billet-highlight) 85%, transparent) 50%,
    transparent 66%
  );
  transform: translateX(calc(var(--billet-pos) * 1%));
  mix-blend-mode: screen;
  opacity: 0.7;
  will-change: transform;
}
[data-crucible="billet"][data-mode="sweep"]:not([data-paused="true"]) .billet-shine {
  animation: crucible-billet-sweep var(--billet-dur) linear infinite;
}
[data-crucible="billet"][data-mode="pulse"] .billet-shine {
  display: none;
}
[data-crucible="billet"][data-mode="pulse"]:not([data-paused="true"]) .billet-block {
  animation: crucible-billet-pulse var(--billet-dur) ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  [data-crucible="billet"] .billet-shine {
    display: none !important;
  }
  [data-crucible="billet"]:not([data-paused="true"]) .billet-block {
    animation: crucible-billet-pulse calc(var(--billet-dur) * 1.5) ease-in-out infinite !important;
  }
}
`;

// A single matte placeholder block with its own light-pass overlay.
function Block({ style }: { style?: React.CSSProperties }) {
  return (
    <div className="billet-block" style={style} aria-hidden>
      <span className="billet-shine" />
    </div>
  );
}

function TextLines({ lines, lastLineWidth, lineRadius }: { lines: number; lastLineWidth: string; lineRadius: number }) {
  return (
    <div className="flex w-full flex-col" style={{ gap: "0.62rem" }}>
      {Array.from({ length: lines }, (_, i) => (
        <Block
          key={i}
          style={{
            height: "0.85rem",
            width: i === lines - 1 && lines > 1 ? lastLineWidth : "100%",
            borderRadius: lineRadius,
          }}
        />
      ))}
    </div>
  );
}

/**
 * Billet — a content-shaped skeleton placeholder: matte graphite blocks (an
 * "unforged billet") crossed by a single cool-white light pass. The pass is a
 * heat pass, not a treadmill — it dwells off the block, then fires once and
 * rests, driven by an @property-registered custom property so the shine tweens
 * smoothly with zero JavaScript. Presets cover the common blanks (card / text
 * / circle / rect) and compose to prefigure any layout. Colors, radius, sweep
 * speed/direction/angle, line count, and a pulse mode are all themeable; the
 * region carries `role="status"` + `aria-busy`, and under reduced motion the
 * sweep becomes a gentle opacity breathe — a living resting frame, never a
 * dead grey box.
 */
export function Billet({
  shape = "card",
  lines = 3,
  lastLineWidth = "62%",
  size = 48,
  speed = 1,
  direction = "right",
  angle = 12,
  radius = 8,
  mode = "sweep",
  baseColor = "#1c1e24",
  highlightColor = "#e6ecf7",
  paused = false,
  label = "Loading",
  className,
  style,
  ...rest
}: BilletProps) {
  const lineCount = Math.max(1, Math.min(Math.round(lines), 12));
  const lineRadius = Math.min(radius, 6);

  const vars = {
    "--billet-base": baseColor,
    "--billet-highlight": highlightColor,
    "--billet-radius": `${radius}px`,
    "--billet-grad-angle": `${90 + angle}deg`,
    "--billet-dur": `${(2.8 / Math.max(speed, 0.05)).toFixed(3)}s`,
    // Percentages the light band translates between (its own width is the
    // block width, so ±120% clears fully off either side).
    "--billet-from": direction === "left" ? "120" : "-120",
    "--billet-to": direction === "left" ? "-120" : "120",
    ...style,
  } as React.CSSProperties;

  const panel: React.CSSProperties = {
    padding: "1rem",
    border: "1px solid rgba(255,255,255,0.06)",
    borderRadius: `calc(var(--billet-radius) + 6px)`,
    background: "rgba(255,255,255,0.015)",
  };

  return (
    <div
      {...rest}
      data-crucible="billet"
      data-mode={mode}
      data-paused={paused ? "true" : undefined}
      role="status"
      aria-busy="true"
      aria-label={label}
      className={cn("relative block w-full", className)}
      style={vars}
    >
      <style>{STYLE}</style>

      {shape === "circle" && (
        <Block style={{ width: size, height: size, borderRadius: 9999 }} />
      )}

      {shape === "rect" && <Block style={{ width: "100%", height: "9rem" }} />}

      {shape === "text" && (
        <TextLines lines={lineCount} lastLineWidth={lastLineWidth} lineRadius={lineRadius} />
      )}

      {shape === "card" && (
        <div className="billet-card w-full" style={panel}>
          <div className="flex items-center" style={{ gap: "0.85rem" }}>
            <Block style={{ width: 44, height: 44, borderRadius: 9999, flex: "0 0 auto" }} />
            <div className="flex min-w-0 flex-1 flex-col" style={{ gap: "0.5rem" }}>
              <Block style={{ height: "0.95rem", width: "55%", borderRadius: lineRadius }} />
              <Block style={{ height: "0.75rem", width: "32%", borderRadius: lineRadius }} />
            </div>
          </div>
          <Block
            style={{ height: "8.5rem", width: "100%", borderRadius: "var(--billet-radius)", marginTop: "1rem" }}
          />
          <div className="flex flex-col" style={{ gap: "0.6rem", marginTop: "1rem" }}>
            {Array.from({ length: lineCount }, (_, i) => (
              <Block
                key={i}
                style={{
                  height: "0.8rem",
                  width: i === lineCount - 1 && lineCount > 1 ? lastLineWidth : "100%",
                  borderRadius: lineRadius,
                }}
              />
            ))}
          </div>
        </div>
      )}

      <span className="sr-only">{label}</span>
    </div>
  );
}
```
