# Touchstone — Animated Section Block for React

> Feature comparison matrix in two modes — plans (3–4 pricing columns) or versus (your product framed in the accent against alternatives). Collapsible row groups, a sticky name/price/CTA header that frosts once pinned, checks that draw in row by row, row hover with floating hints, column-header dimming, and a one-column view with a segmented picker and swipe under 640px. Real table semantics.

- Page: https://crucibleui.com/components/sections/touchstone
- Category: [sections](https://crucibleui.com/components/sections)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

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

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

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

## Props (`TouchstoneProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `mode?` | `"plans" \| "versus"` | `"plans"` | `"plans"` compares 3–4 pricing plans; `"versus"` compares your product (the highlighted column) against 2–3 alternatives. Also picks the sample data when `columns`/`groups` are omitted. |
| `columns?` | `TouchstoneColumn[]` | — | Column definitions. Omit for sample data matching `mode`. |
| `groups?` | `TouchstoneGroup[]` | — | Grouped feature rows. Omit for sample data matching `mode`. |
| `eyebrow?` | `React.ReactNode` | — | Small label above the title. Pass `null` to hide. |
| `title?` | `React.ReactNode` | — | Section heading. Pass `null` to hide the header block. |
| `subtitle?` | `React.ReactNode` | — | Supporting line under the title. |
| `footnote?` | `React.ReactNode` | — | Muted footnote beside the legend. |
| `defaultCollapsed?` | `string[]` | `[]` | Group labels collapsed on first render. |
| `onGroupToggle?` | `(label: string, open: boolean) => void` | — | Fires when a group is expanded or collapsed. |
| `onColumnChange?` | `(index: number) => void` | — | Fires when the narrow-view column changes (picker or swipe). |
| `stickyOffset?` | `number` | `0` | Distance from the top of the scroll container where the column header pins, in px (e.g. a fixed navbar). |
| `accentColor?` | `string` | `"#e9c46a"` | The single signature accent — highlight frame, checks in the highlighted column, primary CTA, focus rings. |
| `ref?` | `React.Ref<TouchstoneHandle>` | — | Imperative handle (`replay()`, `expandAll()`, `collapseAll()`, `showColumn()`). React 19 ref-as-prop. |
| `className?` | `string` | — | Extra classes for the `<section>` root. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"section">, "children" | "title">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Quorum } from "@/components/crucible/quorum";
import { Touchstone, type TouchstoneHandle } from "@/components/crucible/touchstone";

const MODES = [
  { value: "plans", label: "Plans" },
  { value: "versus", label: "Versus" },
];

const sleep = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));

export default function TouchstoneDemo() {
  const ref = React.useRef<TouchstoneHandle>(null);
  const [mode, setMode] = React.useState<"plans" | "versus">("plans");

  return (
    <div className="relative isolate h-[640px] w-full overflow-y-auto overscroll-contain rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72"
        style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(233,196,106,0.07), transparent 70%)" }}
      />
      <div className="relative z-10 flex flex-wrap items-center justify-between gap-3 px-4 pt-5 @container sm:px-8">
        <p className="text-xs text-white/45">Hover a row — narrow the window to compare one plan at a time.</p>
        <div className="flex items-center gap-3">
          <button
            type="button"
            onClick={() => ref.current?.replay()}
            className="rounded text-xs text-white/40 underline underline-offset-4 transition-colors hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
          >
            Replay
          </button>
          <Quorum
            size="sm"
            accent="#e9c46a"
            aria-label="Comparison mode"
            options={MODES}
            value={mode}
            onChange={(v) => setMode(v as "plans" | "versus")}
          />
        </div>
      </div>
      <Touchstone
        ref={ref}
        mode={mode}
        className="relative z-10"
        footnote="Prices in USD. Taxes may apply."
        columns={
          mode === "plans"
            ? [
                { name: "Starter", price: "$0", note: "For solo makers", cta: { label: "Start free", onClick: () => sleep(700) } },
                { name: "Growth", price: "$29", period: "/seat/mo", note: "Billed yearly", cta: { label: "Start trial", onClick: () => sleep(900) }, highlight: true, badge: "Most popular" },
                { name: "Scale", price: "$79", period: "/seat/mo", note: "Billed yearly", cta: { label: "Start trial", onClick: () => sleep(900) } },
                { name: "Enterprise", price: "Custom", note: "Volume pricing", cta: { label: "Talk to sales", onClick: () => sleep(700) } },
              ]
            : undefined
        }
      />
    </div>
  );
}
```

## Accessibility & performance

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