# Fallow

> Empty-state block with five presets (no results, first run, error, offline, inbox zero) and a custom mode. Each preset has an original 1.5px line illustration with one accent detail and a three-cycle idle loop, plus title, description, primary action, secondary link, suggestion chips and an async retry button that runs pending to success.

A shadcn/ui block from the [@crucible](https://crucibleui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/crucible/fallow).

- Page, with a live preview: https://shadcn.exchange/r/crucible/fallow
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/crucible/fallow-light-54363aa395cc7d2c.png
- Registry item: https://crucibleui.com/r/fallow.json
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/crucible/fallow.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://crucibleui.com/r/use-reduced-motion.json`

## Files

### `registry/default/app/fallow/fallow.tsx`

```tsx
"use client";

import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

/** Built-in empty-state presets, plus `"custom"` (bring your own illustration + copy). */
export type FallowKind = "no-results" | "first-run" | "error" | "offline" | "inbox-zero" | "custom";

/** Overall scale of the block. */
export type FallowSize = "sm" | "md" | "lg";

/** A primary or secondary call to action. */
export interface FallowAction {
  /** Visible label. */
  label: React.ReactNode;
  /** Click handler (rendered as a `<button>`). */
  onClick?: (event: React.MouseEvent<HTMLElement>) => void;
  /** Render as a link (`<a href>`) instead of a button. */
  href?: string;
}

/** Imperative verbs exposed through the `ref` prop. */
export interface FallowHandle {
  /** Replay the intro and the illustration's idle loop, and clear any retry state. */
  replay: () => void;
}

export interface FallowProps extends Omit<React.ComponentPropsWithoutRef<"div">, "title" | "children"> {
  /**
   * Which preset to render. Each preset brings its own line illustration and
   * default copy/actions; `"custom"` renders only what you pass.
   * @default "no-results"
   */
  kind?: FallowKind;
  /** Headline. Defaults to the preset's copy. */
  title?: React.ReactNode;
  /** Supporting sentence under the headline. Defaults to the preset's copy. */
  description?: React.ReactNode;
  /**
   * Primary action (accent-filled button). Defaults to the preset's action;
   * pass `null` to hide it. In retry mode (`onRetry` set) its label becomes the
   * retry button's idle label.
   */
  action?: FallowAction | null;
  /** Secondary text link. Defaults to the preset's link; pass `null` to hide it. */
  secondaryAction?: FallowAction | null;
  /**
   * Suggestion chips shown under the copy ("Try 'deploy'"). Defaults to three
   * sample queries for `no-results`, none otherwise; pass `[]` to hide.
   */
  suggestions?: string[];
  /** Fires with the chip's text when a suggestion is chosen. */
  onSuggestion?: (suggestion: string) => void;
  /**
   * Retry mode (meant for `error` / `offline`). When set, the primary button
   * calls this; return a Promise to show a pending spinner, then a success
   * check on resolve, or an inline "still failing" note on reject.
   */
  onRetry?: () => void | Promise<unknown>;
  /** Overall scale: illustration size, type ramp and button height. @default "md" */
  size?: FallowSize;
  /** Wrap the block in a dashed-border panel instead of rendering bare. @default false */
  framed?: boolean;
  /** Replace the preset illustration (decorative; rendered `aria-hidden`). */
  illustration?: React.ReactNode;
  /** Heading element for the title. @default "h3" */
  titleAs?: "h2" | "h3" | "h4" | "p";
  /** Stop the illustration's idle loop (it already stops by itself after 3 cycles). @default false */
  paused?: boolean;
  /** The single accent: illustration detail, primary fill, focus rings. @default "#a3c9a8" (sage) */
  accentColor?: string;
  /** Imperative handle (`replay()`). React 19 ref-as-prop. */
  ref?: React.Ref<FallowHandle>;
  /** Extra classes for the root. */
  className?: string;
}

const DEFAULT_ACCENT = "#a3c9a8";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const INSTANT: Transition = { duration: 0 };
const CYCLES = 3;

interface PresetCopy {
  title: string;
  description: string;
  action?: string;
  secondary?: string;
  suggestions?: string[];
  pending: string;
  success: string;
  failure: string;
}

const PRESETS: Record<FallowKind, PresetCopy> = {
  "no-results": {
    title: "No matches found",
    description: "Nothing matches your search. Check the spelling or try a broader term.",
    action: "Clear search",
    secondary: "Browse all",
    suggestions: ["deploy", "billing", "api keys"],
    pending: "Searching",
    success: "Updated",
    failure: "Still nothing. Try another term.",
  },
  "first-run": {
    title: "Plant your first project",
    description: "Projects keep environments, deploys and teammates in one place. Setup takes about a minute.",
    action: "Create project",
    secondary: "Import from file",
    pending: "Working",
    success: "Done",
    failure: "That didn't work. Try again.",
  },
  error: {
    title: "Something came unplugged",
    description: "We couldn't load this view. Your data is safe; the problem is on our side.",
    action: "Try again",
    secondary: "View status",
    pending: "Retrying",
    success: "Reconnected",
    failure: "Still failing. Give it a moment and try again.",
  },
  offline: {
    title: "You're offline",
    description: "Changes are kept on this device and sync automatically when you reconnect.",
    action: "Reconnect",
    secondary: "Work offline",
    pending: "Reconnecting",
    success: "Back online",
    failure: "No connection yet. Check your network and try again.",
  },
  "inbox-zero": {
    title: "You're all caught up",
    description: "No new notifications. We'll let you know the moment something needs you.",
    action: "View archive",
    secondary: "Notification settings",
    pending: "Working",
    success: "Done",
    failure: "That didn't work. Try again.",
  },
  custom: {
    title: "Nothing here yet",
    description: "",
    pending: "Working",
    success: "Done",
    failure: "That didn't work. Try again.",
  },
};

const SIZES = {
  sm: {
    pad: "px-5 py-6",
    art: 76,
    gapArt: "mb-3",
    title: "text-sm font-semibold",
    desc: "mt-1 max-w-[30ch] text-xs leading-relaxed",
    chips: "mt-3",
    actions: "mt-4",
    btn: "h-8 px-3.5 text-xs",
    link: "text-xs",
    chip: "h-6 px-2 text-[11px]",
  },
  md: {
    pad: "px-6 py-9",
    art: 112,
    gapArt: "mb-5",
    title: "text-base font-semibold",
    desc: "mt-1.5 max-w-sm text-sm leading-relaxed",
    chips: "mt-4",
    actions: "mt-6",
    btn: "h-9 px-4 text-sm",
    link: "text-sm",
    chip: "h-7 px-2.5 text-xs",
  },
  lg: {
    pad: "px-8 py-12",
    art: 136,
    gapArt: "mb-6",
    title: "text-xl font-semibold tracking-tight",
    desc: "mt-2 max-w-md text-[15px] leading-relaxed",
    chips: "mt-5",
    actions: "mt-7",
    btn: "h-10 px-5 text-sm",
    link: "text-sm",
    chip: "h-7 px-3 text-xs",
  },
} as const;

/* ---------------------------------------------------------------- helpers */

/** Black or white text, whichever reads against the accent fill. */
function readableOn(hex: string): string {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return "#0a0a0a";
  let s = m[1];
  if (s.length === 3) s = s.replace(/./g, (c) => c + c);
  const n = parseInt(s, 16);
  const lum = 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
  return lum > 140 ? "#0a0a0a" : "#ffffff";
}

function Spinner() {
  return (
    <svg aria-hidden viewBox="0 0 24 24" className="size-3.5 motion-safe:animate-spin" fill="none">
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
    </svg>
  );
}

function CheckGlyph() {
  return (
    <svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none">
      <path d="M5 12.5l4.5 4.5L19 7.5" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

function ArrowGlyph() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className="size-3.5 transition-transform group-hover:translate-x-0.5" fill="none">
      <path d="M3.5 8h8.5M8.5 4.5 12 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

/* ---------------------------------------------------------------- illustrations */

interface ArtProps {
  accent: string;
  /** Run the idle loop (in view, not paused, motion allowed). */
  play: boolean;
  /** Draw-on intro allowed (false under reduced motion). */
  animateIn: boolean;
  /** The block has entered the viewport (intro may start). */
  entered: boolean;
}

/** Neutral line ink shared by every illustration. */
const INK = "rgba(255,255,255,0.46)";
const INK_SOFT = "rgba(255,255,255,0.2)";
const SW = 1.5;

/** Idle loop: CYCLES repeats, each cycle starting and ending at the rest pose. */
const loop = (play: boolean, duration: number, extra?: Partial<Transition>): Transition =>
  play
    ? { duration, ease: "easeInOut", repeat: CYCLES - 1, delay: 0.75, ...extra }
    : INSTANT;

/** Magnifier sweeping across empty result rows. */
function NoResultsArt({ accent, play }: ArtProps) {
  return (
    <>
      <rect x="22" y="28" width="76" height="64" rx="9" stroke={INK_SOFT} strokeWidth={SW} />
      {[44, 60, 76].map((y, i) => (
        <g key={y}>
          <circle cx="33" cy={y} r="3.5" stroke={INK} strokeWidth={SW} />
          <path d={`M42 ${y}H${i === 1 ? 74 : 86}`} stroke={INK_SOFT} strokeWidth={SW} strokeLinecap="round" strokeDasharray="3 4" />
        </g>
      ))}
      <motion.g
        initial={false}
        animate={play ? { x: [0, -13, 15, 0], y: [0, 2, -2, 0] } : { x: 0, y: 0 }}
        transition={loop(play, 3.2)}
      >
        <circle cx="62" cy="58" r="14" fill="rgba(10,10,10,0.72)" stroke={accent} strokeWidth={SW} />
        <path d="M54 52.5a9 9 0 0 1 6-3.6" stroke={accent} strokeOpacity="0.55" strokeWidth={SW} strokeLinecap="round" />
        <rect x="-2.5" y="0" width="5" height="14" rx="2.5" transform="translate(72 68) rotate(-45)" stroke={INK} strokeWidth={SW} />
      </motion.g>
    </>
  );
}

/** Seedling growing out of a dashed planter. */
function FirstRunArt({ accent, play, animateIn, entered }: ArtProps) {
  const grown = !animateIn || entered;
  return (
    <>
      <path d="M34 78H86" stroke={INK} strokeWidth={SW} strokeLinecap="round" />
      <path d="M38 78L43.5 98H76.5L82 78" stroke={INK} strokeWidth={SW} strokeLinejoin="round" strokeDasharray="3.5 3.5" />
      <path d="M50 88H70" stroke={INK_SOFT} strokeWidth={SW} strokeLinecap="round" strokeDasharray="2 4" />
      <motion.g
        initial={false}
        animate={play ? { rotate: [0, -5, 4, 0] } : { rotate: 0 }}
        transition={loop(play, 3.4)}
        style={{ transformBox: "view-box", transformOrigin: "60px 78px" }}
      >
        <motion.path
          d="M60 78C60 70 59 62 60.5 52"
          stroke={INK}
          strokeWidth={SW}
          strokeLinecap="round"
          initial={animateIn ? { pathLength: 0 } : false}
          animate={{ pathLength: grown ? 1 : 0 }}
          transition={animateIn ? { duration: 0.55, ease: EASE, delay: 0.15 } : INSTANT}
        />
        <motion.g
          initial={animateIn ? { scale: 0, opacity: 0 } : false}
          animate={grown ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
          transition={animateIn ? { type: "spring", stiffness: 260, damping: 22, delay: 0.5 } : INSTANT}
          style={{ transformBox: "view-box", transformOrigin: "60px 60px" }}
        >
          <path d="M60 62C53 62 46.5 58 45 50.5C52.5 50 58.5 54.5 60 62Z" fill={accent} fillOpacity="0.14" stroke={accent} strokeWidth={SW} strokeLinejoin="round" />
          <path d="M60.5 56C63 50 69.5 45.5 77 46C75.5 53.5 68 57.5 60.5 56Z" fill={accent} fillOpacity="0.14" stroke={accent} strokeWidth={SW} strokeLinejoin="round" />
        </motion.g>
      </motion.g>
    </>
  );
}

/** Plug pulled out of its socket, twitching toward it. */
function ErrorArt({ accent, play }: ArtProps) {
  const t = { duration: 2.6, times: [0, 0.06, 0.11, 0.17, 0.26, 1] };
  return (
    <>
      {/* Socket + its cable */}
      <rect x="72" y="44" width="26" height="32" rx="7" stroke={INK} strokeWidth={SW} />
      <path d="M81 54v8M89 54v8" stroke={INK} strokeWidth={SW} strokeLinecap="round" />
      <path d="M98 60h6c4 0 6 3 6 7v24" stroke={INK_SOFT} strokeWidth={SW} strokeLinecap="round" />
      {/* Plug + cable */}
      <motion.g
        initial={false}
        animate={play ? { x: [0, 4.5, 0.5, 3, 0, 0], rotate: [0, -2, 1, -1, 0, 0] } : { x: 0, rotate: 0 }}
        transition={play ? { ...t, ease: "easeOut", repeat: CYCLES - 1, delay: 0.75 } : INSTANT}
        style={{ transformBox: "view-box", transformOrigin: "30px 60px" }}
      >
        <rect x="30" y="48" width="20" height="24" rx="5" stroke={INK} strokeWidth={SW} />
        <path d="M50 55h7M50 65h7" stroke={INK} strokeWidth={SW} strokeLinecap="round" />
        <path d="M35 56v8" stroke={INK_SOFT} strokeWidth={SW} strokeLinecap="round" />
        <path d="M30 60c-8 0-11 4-12 10s-3 12-10 14" stroke={INK} strokeWidth={SW} strokeLinecap="round" />
      </motion.g>
      {/* Spark — the accent detail */}
      <motion.g
        initial={false}
        animate={play ? { opacity: [0.85, 0.25, 1, 0.3, 0.85, 0.85] } : { opacity: 0.85 }}
        transition={play ? { ...t, ease: "linear", repeat: CYCLES - 1, delay: 0.75 } : INSTANT}
        stroke={accent}
        strokeWidth={SW}
        strokeLinecap="round"
      >
        <path d="M64.5 47.5v4" />
        <path d="M58.5 49.5l2 3" />
        <path d="M70.5 49.5l-2 3" />
        <path d="M64.5 72.5v-4" />
      </motion.g>
    </>
  );
}

/** Wi-fi arcs fading bar by bar, outermost first. */
function OfflineArt({ accent, play }: ArtProps) {
  const cx = 60;
  const cy = 84;
  const arc = (r: number) => {
    const d = r * Math.SQRT1_2;
    return `M${cx - d} ${cy - d}A${r} ${r} 0 0 1 ${cx + d} ${cy - d}`;
  };
  // Rest opacities read as "weak signal"; index 0 = innermost.
  const rest = [0.7, 0.3, 0.14];
  return (
    <>
      <path d="M30 98H90" stroke={INK_SOFT} strokeWidth={SW} strokeLinecap="round" strokeDasharray="2 5" />
      {[13, 26, 39].map((r, i) => {
        // Outer arcs fade first, inner last.
        const order = 2 - i;
        const start = 0.18 + order * 0.16;
        return (
          <motion.path
            key={r}
            d={arc(r)}
            stroke={INK}
            strokeWidth={SW}
            strokeLinecap="round"
            initial={false}
            animate={play ? { opacity: [rest[i], 1, 1, rest[i], rest[i]] } : { opacity: rest[i] }}
            transition={
              play
                ? {
                    duration: 3,
                    times: [0, 0.12, start, start + 0.16, 1],
                    ease: "easeInOut",
                    repeat: CYCLES - 1,
                    delay: 0.75,
                  }
                : INSTANT
            }
          />
        );
      })}
      <circle cx={cx} cy={cy} r="3.5" fill={accent} />
      <circle cx={cx} cy={cy} r="7.5" stroke={accent} strokeOpacity="0.35" strokeWidth={SW} />
    </>
  );
}

/** A tray with a single paper plane gliding off. */
function InboxZeroArt({ accent, play }: ArtProps) {
  return (
    <>
      <path
        d="M26 72L35 54H85L94 72V90a5 5 0 0 1-5 5H31a5 5 0 0 1-5-5Z"
        stroke={INK}
        strokeWidth={SW}
        strokeLinejoin="round"
      />
      <path d="M26 72H46l4 7h20l4-7H94" stroke={INK} strokeWidth={SW} strokeLinejoin="round" strokeLinecap="round" />
      <path d="M38 40c6-8 14-11 22-9" stroke={INK_SOFT} strokeWidth={SW} strokeLinecap="round" strokeDasharray="2 4" />
      <motion.g
        initial={false}
        animate={
          play
            ? { x: [0, 30, -14, -14, 0], y: [0, -20, 18, 18, 0], opacity: [1, 0, 0, 0, 1] }
            : { x: 0, y: 0, opacity: 1 }
        }
        transition={
          play
            ? { duration: 3.4, times: [0, 0.42, 0.5, 0.6, 1], ease: "easeInOut", repeat: CYCLES - 1, delay: 0.75 }
            : INSTANT
        }
      >
        <path d="M62 36L86 26L79 46L73 39Z" fill={accent} fillOpacity="0.14" stroke={accent} strokeWidth={SW} strokeLinejoin="round" />
        <path d="M73 39L86 26" stroke={accent} strokeWidth={SW} strokeLinecap="round" />
        <path d="M73 39L72 45L76 42" stroke={accent} strokeWidth={SW} strokeLinejoin="round" strokeLinecap="round" />
      </motion.g>
    </>
  );
}

const ART: Partial<Record<FallowKind, React.ComponentType<ArtProps>>> = {
  "no-results": NoResultsArt,
  "first-run": FirstRunArt,
  error: ErrorArt,
  offline: OfflineArt,
  "inbox-zero": InboxZeroArt,
};

/* ---------------------------------------------------------------- main */

type RetryPhase = "idle" | "pending" | "success" | "failed";

/**
 * Fallow — an empty-state block with five presets (`no-results`, `first-run`,
 * `error`, `offline`, `inbox-zero`) and a custom mode. Each preset carries an
 * original 1.5px line illustration with a single accent detail and a gentle
 * idle loop that plays three cycles once in view, then rests: a magnifier
 * sweeping empty rows, a seedling in a dashed planter, a twitching unplugged
 * cable, Wi-fi arcs fading bar by bar, a paper plane gliding off a tray.
 * Headline, description, an accent primary action and a secondary link, an
 * optional suggestion-chip row, and a retry mode whose button runs an async
 * `onRetry` through pending → success (or an inline "still failing" note).
 * The copy sits in a `role="status"` region so swaps and retry results are
 * announced. Reduced motion renders the rest pose with crossfades only.
 */
export function Fallow({
  kind = "no-results",
  title,
  description,
  action,
  secondaryAction,
  suggestions,
  onSuggestion,
  onRetry,
  size = "md",
  framed = false,
  illustration,
  titleAs = "h3",
  paused = false,
  accentColor = DEFAULT_ACCENT,
  ref,
  className,
  style,
  ...props
}: FallowProps) {
  const reducedMotion = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [seen, setSeen] = React.useState(false);
  const [run, setRun] = React.useState(0);
  // Retry state is tagged with the preset it belongs to, so switching `kind`
  // starts with a clean button (and late results from the old preset are ignored).
  const [retryState, setRetryState] = React.useState<{ kind: FallowKind; phase: RetryPhase }>({
    kind,
    phase: "idle",
  });
  const retry: RetryPhase = retryState.kind === kind ? retryState.phase : "idle";
  const setRetry = React.useCallback((phase: RetryPhase, forKind: FallowKind) => setRetryState({ kind: forKind, phase }), []);
  const retryToken = React.useRef(0);

  const preset = PRESETS[kind];
  const sz = SIZES[size];
  const ink = readableOn(accentColor);
  const Title = titleAs;

  // Designed intro: plays the first time the block scrolls into view.
  React.useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setSeen(true);
          observer.disconnect();
        }
      },
      { threshold: 0.2 }
    );
    observer.observe(el);
    // Belt and braces: if already on screen at mount, don't wait on the observer.
    const raf = window.requestAnimationFrame(() => {
      const r = el.getBoundingClientRect();
      if (r.width > 0 && r.bottom > 0 && r.top < window.innerHeight) {
        setSeen(true);
        observer.disconnect();
      }
    });
    return () => {
      window.cancelAnimationFrame(raf);
      observer.disconnect();
    };
  }, []);

  const replay = React.useCallback(() => {
    retryToken.current++;
    setRetry("idle", kind);
    setRun((r) => r + 1);
  }, [kind, setRetry]);
  React.useImperativeHandle(ref, () => ({ replay }), [replay]);

  const shown = reducedMotion || seen;
  // On replay() the subtree remounts (key={run}) and starts from the hidden pose.
  const replaying = run > 0 && !reducedMotion;
  const reveal = (delay: number) => ({
    initial: replaying ? { opacity: 0, y: 10 } : (false as const),
    animate: { opacity: shown ? 1 : 0, y: shown ? 0 : 10 },
    transition: reducedMotion ? INSTANT : { duration: 0.5, ease: EASE, delay },
  });

  const primary: FallowAction | null =
    action === null ? null : action ?? (preset.action ? { label: preset.action } : null);
  const secondary: FallowAction | null =
    secondaryAction === null ? null : secondaryAction ?? (preset.secondary ? { label: preset.secondary } : null);
  const chips = suggestions ?? preset.suggestions ?? [];
  const retryMode = Boolean(onRetry);
  const showPrimary = retryMode || primary !== null;
  const titleText = title ?? preset.title;
  const descText = description ?? preset.description;

  const handleRetry = async () => {
    if (!onRetry || retry === "pending" || retry === "success") return;
    const token = ++retryToken.current;
    const forKind = kind;
    setRetry("pending", forKind);
    try {
      await onRetry();
      if (token === retryToken.current) setRetry("success", forKind);
    } catch {
      if (token === retryToken.current) setRetry("failed", forKind);
    }
  };

  const Art = ART[kind];
  const art = illustration ?? (Art ? (
    <svg viewBox="0 0 120 120" fill="none" className="size-full overflow-visible">
      <circle cx="60" cy="62" r="54" fill="rgba(255,255,255,0.018)" />
      <circle cx="60" cy="62" r="54" stroke="rgba(255,255,255,0.06)" strokeWidth="1" strokeDasharray="1.5 5" />
      <Art accent={accentColor} play={shown && !reducedMotion && !paused} animateIn={!reducedMotion} entered={shown} />
    </svg>
  ) : null);

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--fallow-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950";

  const primaryLabel: React.ReactNode =
    retry === "pending" ? preset.pending : retry === "success" ? preset.success : primary?.label ?? preset.action ?? "Try again";

  const primaryClass = cn(
    "inline-flex shrink-0 items-center justify-center gap-1.5 rounded-lg font-medium whitespace-nowrap",
    "shadow-[inset_0_1px_0_rgba(255,255,255,0.25),0_1px_2px_rgba(0,0,0,0.4)] transition-[filter,transform] hover:brightness-110 active:scale-[0.98]",
    sz.btn,
    focusRing
  );

  const announce =
    retry === "pending"
      ? `${preset.pending}…`
      : retry === "success"
        ? `${preset.success}.`
        : retry === "failed"
          ? preset.failure
          : "";

  return (
    <div
      ref={rootRef}
      data-crucible="fallow"
      className={cn("@container relative w-full text-white", className)}
      style={{ ["--fallow-accent" as string]: accentColor, ...style }}
      {...props}
    >
      <div
        key={run}
        className={cn(
          "flex flex-col items-center text-center",
          sz.pad,
          framed && "rounded-2xl border border-dashed border-white/[0.12] bg-white/[0.015]"
        )}
      >
        {art ? (
          <motion.div
            aria-hidden
            className={cn("relative isolate shrink-0", sz.gapArt)}
            style={{ width: sz.art, height: sz.art }}
            initial={replaying ? { opacity: 0, scale: 0.94 } : false}
            animate={{ opacity: shown ? 1 : 0, scale: shown ? 1 : 0.94 }}
            transition={reducedMotion ? INSTANT : { duration: 0.55, ease: EASE }}
          >
            <div
              className="pointer-events-none absolute inset-[-20%] -z-10 rounded-full opacity-60 blur-2xl"
              style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${accentColor} 14%, transparent), transparent)` }}
            />
            {art}
          </motion.div>
        ) : null}

        <div role="status" aria-live="polite" className="flex flex-col items-center">
          <motion.div {...reveal(0.08)}>
            <Title className={cn("text-balance text-white", sz.title)}>{titleText}</Title>
          </motion.div>
          {descText ? (
            <motion.p className={cn("text-pretty text-white/55", sz.desc)} {...reveal(0.14)}>
              {descText}
            </motion.p>
          ) : null}
          <span className="sr-only">{announce}</span>
        </div>

        {chips.length > 0 ? (
          <motion.div className={cn("flex flex-wrap items-center justify-center gap-1.5", sz.chips)} {...reveal(0.2)}>
            <span className={cn("mr-0.5 text-white/40", size === "sm" ? "text-[11px]" : "text-xs")}>Try</span>
            {chips.map((chip) => (
              <button
                key={chip}
                type="button"
                onClick={() => onSuggestion?.(chip)}
                className={cn(
                  "inline-flex items-center rounded-full border border-white/[0.09] bg-white/[0.03] font-medium text-white/70 transition-colors",
                  "hover:border-[color:var(--fallow-accent)]/50 hover:bg-white/[0.06] hover:text-white",
                  sz.chip,
                  focusRing
                )}
              >
                <span className="text-white/35">&lsquo;</span>
                {chip}
                <span className="text-white/35">&rsquo;</span>
              </button>
            ))}
          </motion.div>
        ) : null}

        {showPrimary || secondary ? (
          <motion.div
            className={cn("flex w-full flex-col items-center justify-center gap-2.5 @xs:w-auto @xs:flex-row @xs:gap-4", sz.actions)}
            {...reveal(0.26)}
          >
            {showPrimary ? (
              retryMode ? (
                <motion.button
                  type="button"
                  layout={!reducedMotion}
                  transition={{ layout: reducedMotion ? INSTANT : { type: "spring", stiffness: 420, damping: 34 } }}
                  onClick={handleRetry}
                  aria-busy={retry === "pending" || undefined}
                  aria-disabled={retry === "pending" || retry === "success" || undefined}
                  className={cn(primaryClass, "w-full @xs:w-auto", retry === "success" && "cursor-default hover:brightness-100 active:scale-100")}
                  style={{ backgroundColor: accentColor, color: ink }}
                >
                  <AnimatePresence mode="popLayout" initial={false}>
                    <motion.span
                      key={retry === "failed" ? "idle" : retry}
                      className="inline-flex items-center gap-1.5"
                      initial={{ opacity: 0, y: reducedMotion ? 0 : 6 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: reducedMotion ? 0 : -6 }}
                      transition={reducedMotion ? { duration: 0.12 } : { duration: 0.2, ease: "easeOut" }}
                    >
                      {retry === "pending" ? <Spinner /> : retry === "success" ? <CheckGlyph /> : null}
                      {primaryLabel}
                    </motion.span>
                  </AnimatePresence>
                </motion.button>
              ) : primary?.href ? (
                <a
                  href={primary.href}
                  onClick={primary.onClick}
                  className={cn(primaryClass, "w-full @xs:w-auto")}
                  style={{ backgroundColor: accentColor, color: ink }}
                >
                  {primary.label}
                </a>
              ) : (
                <button
                  type="button"
                  onClick={primary?.onClick}
                  className={cn(primaryClass, "w-full @xs:w-auto")}
                  style={{ backgroundColor: accentColor, color: ink }}
                >
                  {primary?.label}
                </button>
              )
            ) : null}
            {secondary ? (
              secondary.href ? (
                <a
                  href={secondary.href}
                  onClick={secondary.onClick}
                  className={cn("group inline-flex items-center gap-1 whitespace-nowrap rounded font-medium text-white/60 transition-colors hover:text-white", sz.link, focusRing)}
                >
                  {secondary.label}
                  <ArrowGlyph />
                </a>
              ) : (
                <button
                  type="button"
                  onClick={secondary.onClick}
                  className={cn("group inline-flex items-center gap-1 whitespace-nowrap rounded font-medium text-white/60 transition-colors hover:text-white", sz.link, focusRing)}
                >
                  {secondary.label}
                  <ArrowGlyph />
                </button>
              )
            ) : null}
          </motion.div>
        ) : null}

        <AnimatePresence initial={false}>
          {retry === "failed" ? (
            <motion.p
              key="failed"
              className={cn("mt-3 text-white/50", size === "sm" ? "text-[11px]" : "text-xs")}
              initial={{ opacity: 0, height: 0 }}
              animate={{ opacity: 1, height: "auto" }}
              exit={{ opacity: 0, height: 0 }}
              transition={reducedMotion ? { duration: 0.12 } : { duration: 0.25, ease: EASE }}
              aria-hidden
            >
              <span className="mr-1.5 inline-block size-1.5 -translate-y-px rounded-full bg-amber-300/80 align-middle" />
              {preset.failure}
            </motion.p>
          ) : null}
        </AnimatePresence>
      </div>
    </div>
  );
}
```
