# Plan comparison

> Compare meaningful differences between plans and billing periods.

A shadcn/ui block from the [@silicon-ui](https://ui.teamofsilicons.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/silicon-ui/plan-comparison).

- Page, with a live preview: https://shadcn.exchange/r/silicon-ui/plan-comparison
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/silicon-ui/plan-comparison-light-27dab7c02986f491.png
- Registry item: https://ui.teamofsilicons.com/r/plan-comparison.json
- Categories: blocks
- Listed: 2026-10-17, source last changed 2026-10-17

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/silicon-ui/plan-comparison.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: `@radix-ui/react-switch`, `lucide-react`, `motion`
- Registry items: `https://ui.teamofsilicons.com/r/silicon-motion-tokens.json`, `https://ui.teamofsilicons.com/r/button.json`, `https://ui.teamofsilicons.com/r/segmented-control.json`, `https://ui.teamofsilicons.com/r/switch.json`

## Notes from the registry

Docs and live preview: https://ui.teamofsilicons.com/plan-comparison

## Files

### `registry/blocks/plan-comparison/plan-comparison.tsx`

```tsx
"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { animate, AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check } from "lucide-react";
import { Button } from "../../button/button";
import SegmentedControl from "../../segmented-control/segmented-control";
import { Switch } from "../../switch/switch";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./plan-comparison.module.css";

type Plan = "team" | "studio";
type Billing = "monthly" | "yearly";
type Feature = { label: string; team: string; studio: string; shared?: boolean };

const features: Feature[] = [
  { label: "Active projects", team: "Unlimited", studio: "Unlimited", shared: true },
  { label: "Shared workspaces", team: "1 workspace", studio: "Unlimited" },
  { label: "Guest reviewers", team: "5 per project", studio: "Unlimited" },
  { label: "Version history", team: "30 days", studio: "Unlimited" },
  { label: "Approval flows", team: "Not included", studio: "Included" },
  { label: "Custom roles", team: "Not included", studio: "Included" },
  { label: "Source exports", team: "Included", studio: "Included", shared: true },
  { label: "Support", team: "Email", studio: "Priority email" },
];

const pricing: Record<Plan, Record<Billing, number>> = {
  team: { monthly: 13, yearly: 10 },
  studio: { monthly: 27, yearly: 22 },
};

function Value({ text }: { text: string }) {
  if (text === "Included") return <span className={styles.included}><Check size={16} strokeWidth={1.75} aria-hidden="true" />Included</span>;
  return <span className={text === "Not included" ? styles.unavailable : undefined}>{text}</span>;
}

function AnimatedNumber({ value }: { value: number }) {
  const reduce = useReducedMotion();
  const [displayValue, setDisplayValue] = useState(value);
  const previousValue = useRef(value);

  useEffect(() => {
    if (reduce) {
      previousValue.current = value;
      return;
    }

    const controls = animate(previousValue.current, value, {
      duration: motionTokens.duration.standard,
      ease: [...motionTokens.ease.enter],
      onUpdate: (latest) => setDisplayValue(Math.round(latest)),
    });

    previousValue.current = value;
    return () => controls.stop();
  }, [reduce, value]);

  return <>{reduce ? value : displayValue}</>;
}

export function PlanComparison() {
  const id = useId();
  const reduce = useReducedMotion();
  const [billing, setBilling] = useState<Billing>("monthly");
  const [differencesOnly, setDifferencesOnly] = useState(false);
  const [selected, setSelected] = useState<Plan | null>(null);
  const rootRef = useRef<HTMLElement>(null);
  const [size, setSize] = useState<"lg" | "md" | "sm" | "xs">("lg");
  useLayoutEffect(() => {
    const node = rootRef.current;
    if (!node) return;
    const measure = (width: number) => setSize(width <= 340 ? "xs" : width <= 540 ? "sm" : width <= 720 ? "md" : "lg");
    measure(node.getBoundingClientRect().width);
    const observer = new ResizeObserver(([entry]) => { if (entry) measure(entry.contentRect.width); });
    observer.observe(node);
    return () => observer.disconnect();
  }, []);
  const visibleFeatures = differencesOnly ? features.filter((feature) => !feature.shared) : features;

  return (
    <section ref={rootRef} className={styles.comparison} data-size={size} aria-labelledby={`${id}-title`}>
      <div className={styles.intro}>
        <div><h2 id={`${id}-title`}>Room for the way you work</h2><p>Only the details that change between plans. Switch billing to see what you would pay.</p></div>
        <div className={styles.billing}><SegmentedControl label="Billing period" value={billing} onValueChange={(value) => setBilling(value as Billing)} options={[{ value: "monthly", label: "Monthly" }, { value: "yearly", label: "Yearly" }]} /><small>{billing === "yearly" ? "Billed yearly, save up to 23%" : "Billed month to month"}</small></div>
      </div>

      <div className={styles.filter}><div><h3>Compare plans</h3><span className={styles.featureCount}><AnimatedNumber value={visibleFeatures.length} /> of {features.length} features</span></div><Switch checked={differencesOnly} onCheckedChange={setDifferencesOnly} label="Show differences only" /></div>

      <div className={styles.matrix} role="table" aria-label="Team and Studio plan comparison">
        <div className={styles.planHeader} role="row">
          <div className={styles.featureHeader} role="columnheader">What changes</div>
          {(["team", "studio"] as const).map((plan) => (
            <div key={plan} className={`${styles.planCell} ${selected === plan ? styles.planSelected : ""}`} role="columnheader" aria-label={`${plan === "team" ? "Team" : "Studio"} plan`}>
              <div className={styles.planTop}><h3>{plan === "team" ? "Team" : "Studio"}</h3><span>{plan === "team" ? "For smaller teams" : "For work across teams"}</span></div>
              <div className={styles.price}><span className={styles.priceValue} aria-live="polite" aria-atomic="true">$<AnimatedNumber value={pricing[plan][billing]} /></span><span className={styles.pricePeriod}>per seat / month</span></div>
              <Button type="button" variant={selected === plan ? "primary" : "secondary"} className={styles.selectButton} aria-pressed={selected === plan} onClick={() => setSelected(current => current === plan ? null : plan)}>{selected === plan ? "Selected" : `Select ${plan === "team" ? "Team" : "Studio"}`}</Button>
              {selected === plan && <motion.span className={styles.selectedRail} layoutId={`${id}-selected-rail`} transition={reduce ? { duration: 0 } : motionTokens.spring.responsive} aria-hidden="true" />}
            </div>
          ))}
        </div>

        <div className={styles.rows} role="rowgroup">
          <AnimatePresence initial={false}>
            {visibleFeatures.map((feature) => (
              <motion.div key={feature.label} className={styles.featureRow} role="row" initial={reduce ? false : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={reduce ? undefined : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } } }} transition={reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: .04 } }}>
                <div className={styles.featureName} role="rowheader">{feature.label}</div>
                <div className={`${styles.featureValue} ${selected === "team" ? styles.valueSelected : ""}`} role="cell"><span className={styles.mobilePlan}>Team</span><Value text={feature.team} /></div>
                <div className={`${styles.featureValue} ${selected === "studio" ? styles.valueSelected : ""}`} role="cell"><span className={styles.mobilePlan}>Studio</span><Value text={feature.studio} /></div>
              </motion.div>
            ))}
          </AnimatePresence>
        </div>
      </div>

      <div className={styles.footer}><span>Prices in USD per seat. Yearly plans are billed annually.</span><p className={styles.srOnly} role="status" aria-live="polite">{selected ? `${selected === "team" ? "Team" : "Studio"} selected` : ""}</p></div>
    </section>
  );
}

export default PlanComparison;
```

### `registry/blocks/plan-comparison/plan-comparison.module.css`

```css
.comparison { box-sizing: border-box; width: min(100%, 1060px); min-width: 0; margin-inline: auto; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-container); background: var(--surface); color: var(--foreground); font-family: var(--font-body); font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); }
.comparison *, .comparison *::before, .comparison *::after { box-sizing: border-box; }

/* Intro */
.intro { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-inset) var(--space-block); padding: var(--space-block) var(--space-block) var(--space-section); }
.intro > div:first-child { min-width: 0; max-width: 560px; }
.intro h2 { margin: 0; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 400; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.intro p { max-width: 46ch; margin: var(--space-related) 0 0; color: var(--text-secondary); text-wrap: pretty; }
.billing { display: grid; flex: none; justify-items: end; gap: var(--space-related); }
.billing small { min-height: 16px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }

/* Filter bar */
.filter { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: var(--space-related) var(--space-inset); border-top: 1px solid var(--border); padding: var(--space-related) var(--space-block); }
.filter > div:first-child { display: flex; align-items: baseline; gap: var(--space-group); }
.filter h3 { margin: 0; font-size: var(--text-sm); font-weight: 500; }
.featureCount { min-width: 11ch; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.filter > button { flex: none; }

/* Matrix */
.matrix { border-top: 1px solid var(--border); }
.planHeader, .featureRow { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) minmax(0, 1fr); }
/* Plan cells share three row tracks (name, price, button), so a description that wraps in one column keeps prices and buttons level. */
.planHeader { grid-template-rows: auto auto auto; }
.featureHeader { grid-row: 1 / span 3; display: flex; align-items: end; padding: 0 var(--space-inset) var(--space-comfort) var(--space-block); color: var(--text-muted); font-size: var(--text-xs); }
.planCell { position: relative; display: grid; grid-row: span 3; grid-template-rows: subgrid; min-width: 0; align-content: start; border-left: 1px solid var(--border); padding: var(--space-comfort); transition: background-color var(--duration-standard) var(--ease-standard); }
.planCell.planSelected, .featureValue.valueSelected { background: var(--surface-muted); }
.planTop h3 { margin: 0; font-size: var(--text-base); font-weight: 500; letter-spacing: -.01em; }
.planTop > span { display: block; margin-top: var(--space-hair); color: var(--text-muted); font-size: var(--text-xs); }
.price { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-hair) var(--space-related); margin: var(--space-inset) 0 var(--space-inset); }
.priceValue { display: inline-flex; min-width: 3ch; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 400; letter-spacing: var(--tracking-display); line-height: 1; font-variant-numeric: tabular-nums; }
.pricePeriod { color: var(--text-muted); font-size: var(--text-xs); white-space: nowrap; }
.selectButton { width: 100%; }
.selectedRail { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--accent); }
.rows { border-top: 1px solid var(--border); }
.featureRow { overflow: hidden; border-bottom: 1px solid var(--border-subtle); }
.featureRow:last-child { border-bottom: 0; }
.featureName, .featureValue { display: flex; min-width: 0; min-height: 44px; align-items: center; padding: var(--space-group) var(--space-comfort); }
.featureName { padding-left: var(--space-block); color: var(--foreground); }
.featureValue { border-left: 1px solid var(--border); color: var(--text-secondary); transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
.featureValue.valueSelected { color: var(--foreground); }
.included { display: inline-flex; align-items: center; gap: var(--space-related); color: var(--foreground); }
.included svg { color: var(--success); }
.unavailable { color: var(--text-muted); }
.mobilePlan { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.footer { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: var(--space-related) var(--space-section); border-top: 1px solid var(--border); padding: var(--space-group) var(--space-block); color: var(--text-muted); font-size: var(--text-xs); }
.footer p { min-height: 1.4em; margin: 0; color: var(--text-secondary); text-align: right; }

@media (hover: hover) and (pointer: fine) {
  .featureRow:hover .featureName { color: var(--foreground); }
}

/* Layout follows the block width (data-size, measured in the component), not the viewport. */
/* Medium: keep three columns, tighten the gutters. */
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .intro { flex-direction: column; align-items: flex-start; padding: var(--space-section) var(--space-comfort) var(--space-comfort); }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .billing { justify-items: start; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .filter { padding-inline: var(--space-comfort); }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .featureHeader, .comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .featureName { padding-left: var(--space-comfort); }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .planCell { padding: var(--space-inset) var(--space-inset); }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .featureValue { padding-inline: var(--space-inset); }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .footer { flex-direction: column; align-items: flex-start; padding-inline: var(--space-comfort); }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .footer p { text-align: left; }

/* Narrow: the feature name sits above its two values, which stay aligned under the plan columns. */
.comparison:is([data-size="sm"], [data-size="xs"]) .intro h2 { font-size: var(--text-2xl); }
.comparison:is([data-size="sm"], [data-size="xs"]) .filter { flex-wrap: wrap; }
.comparison:is([data-size="sm"], [data-size="xs"]) .planHeader, .comparison:is([data-size="sm"], [data-size="xs"]) .featureRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureHeader { display: none; }
.comparison:is([data-size="sm"], [data-size="xs"]) .planCell { border-left: 0; }
.comparison:is([data-size="sm"], [data-size="xs"]) .planCell + .planCell { border-left: 1px solid var(--border); }
.comparison:is([data-size="sm"], [data-size="xs"]) .priceValue { font-size: var(--text-2xl); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureName { grid-column: 1 / -1; min-height: 0; padding: var(--space-group) var(--space-comfort) 0; color: var(--text-secondary); font-size: var(--text-xs); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureValue { min-height: 0; border-left: 0; padding: var(--space-tight) var(--space-inset) var(--space-group); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureValue:nth-child(2) { padding-left: var(--space-comfort); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureValue + .featureValue { border-left: 1px solid var(--border-subtle); }
.comparison:is([data-size="sm"], [data-size="xs"]) .planCell:nth-child(2) { padding-left: var(--space-comfort); }
.comparison[data-size="xs"] .planCell { padding-inline: var(--space-group); }
.comparison[data-size="xs"] .planCell:nth-child(2) { padding-left: var(--space-inset); }
.comparison[data-size="xs"] .featureValue { padding-inline: var(--space-group); }
.comparison[data-size="xs"] .featureValue:nth-child(2) { padding-left: var(--space-inset); }
.comparison[data-size="xs"] .featureName { padding-inline: var(--space-inset); }
.comparison[data-size="xs"] .intro, .comparison[data-size="xs"] .filter, .comparison[data-size="xs"] .footer { padding-inline: var(--space-inset); }
@media (prefers-reduced-motion: reduce) {
  .planCell, .featureValue { transition: none; }
}

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
```

### `registry/components/switch/switch.tsx`

```tsx
"use client";

import { forwardRef, useEffect, useRef, useState } from "react";
import type { ComponentPropsWithoutRef, ElementRef } from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { animate, motion, useMotionValue, useReducedMotion } from "motion/react";
import type { Transition } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./switch.module.css";

export interface SwitchProps extends ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> {
  label?: string;
}

/** Track inner width (42 - 6 padding) minus the 18px thumb; keep in sync with switch.module.css. */
const size = 18;
const travel = 18;
/** How far the thumb widens toward the other side while pressed. */
const stretch = 5;
/** Critically damped: the thumb lands on its end without overshooting the state it reports. */
const glide: Transition = { type: "spring", visualDuration: 0.3, bounce: 0 };

export const Switch = forwardRef<ElementRef<typeof SwitchPrimitive.Root>, SwitchProps>(function Switch(
  { label, className, checked, defaultChecked, onCheckedChange, onPointerDown, onPointerUp, onPointerLeave, onPointerCancel, onKeyDown, onKeyUp, onBlur, ...props },
  ref,
) {
  const reduceMotion = useReducedMotion();
  const [internal, setInternal] = useState(defaultChecked ?? false);
  const [pressed, setPressed] = useState(false);
  const on = checked ?? internal;
  // A brief stretch along the travel, so the thumb reads as moving mass rather than a sliding dot.
  const scaleX = useMotionValue(1);
  const shown = useRef(on);
  // A pointer or Space press already stretched the thumb, so its release should not add a second stretch on top.
  const releasedAt = useRef(-Infinity);
  useEffect(() => {
    if (shown.current === on) return;
    shown.current = on;
    const fromPress = performance.now() - releasedAt.current < 250;
    if (reduceMotion || fromPress) return;
    const controls = animate(scaleX, [1, 1.16, 1], { duration: 0.34, times: [0, 0.4, 1], ease: ["easeOut", "easeInOut"] });
    return () => controls.stop();
  }, [on, reduceMotion, scaleX]);
  const extra = pressed && !reduceMotion && !props.disabled ? stretch : 0;
  const classes = [styles.switch, className].filter(Boolean).join(" ");

  return (
    <SwitchPrimitive.Root
      {...props}
      ref={ref}
      checked={on}
      onCheckedChange={next => { if (checked === undefined) setInternal(next); onCheckedChange?.(next); }}
      onPointerDown={event => { onPointerDown?.(event); if (event.button === 0) setPressed(true); }}
      onPointerUp={event => { onPointerUp?.(event); if (pressed && !props.disabled) releasedAt.current = performance.now(); setPressed(false); }}
      onPointerLeave={event => { onPointerLeave?.(event); setPressed(false); }}
      onPointerCancel={event => { onPointerCancel?.(event); setPressed(false); }}
      onKeyDown={event => { onKeyDown?.(event); if (event.key === " ") setPressed(true); }}
      onKeyUp={event => { onKeyUp?.(event); if (pressed && !props.disabled) releasedAt.current = performance.now(); setPressed(false); }}
      onBlur={event => { onBlur?.(event); setPressed(false); }}
      className={classes}
      aria-label={props["aria-label"] ?? label}
    >
      <span className={styles.track}>
        {/* The thumb stretches like a held finger and keeps its far edge anchored, then travels on a spring. */}
        <motion.span className={styles.thumb} style={{ scaleX }} initial={false} animate={{ x: on ? travel - extra : 0, width: size + extra }} transition={reduceMotion ? { duration: 0 } : { x: glide, width: motionTokens.spring.snappy }} />
      </span>
      {label ? <span className={styles.label}>{label}</span> : null}
    </SwitchPrimitive.Root>
  );
});

Switch.displayName = "Switch";

export default Switch;
```

### `registry/components/switch/switch.module.css`

```css
.switch { display: inline-flex; min-height: var(--control-height-md); align-items: center; gap: var(--space-group); padding: 0; border: 0; color: var(--foreground); background: transparent; font: inherit; font-size: var(--text-sm); cursor: pointer; -webkit-tap-highlight-color: transparent; }
.switch:disabled { cursor: not-allowed; opacity: .5; }
/* Fixed box: the thumb moves inside it with transforms, so nothing around the switch shifts. The track crossfades between the two fills. */
.track { position: relative; box-sizing: border-box; display: flex; flex: 0 0 auto; width: 42px; height: 24px; align-items: center; padding: var(--space-tight); border-radius: var(--radius-full); corner-shape: round; background: var(--control-track); isolation: isolate; transition: background-color var(--duration-fast) var(--ease-standard); }
.track::before { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; corner-shape: inherit; background: var(--control-on); opacity: 0; transition: opacity var(--duration-standard) var(--ease-standard); }
.switch[data-state="checked"] .track::before { opacity: 1; }
.thumb { flex: 0 0 auto; width: 18px; height: 18px; border-radius: var(--radius-full); corner-shape: round; background: var(--control-thumb); box-shadow: var(--control-thumb-shadow); will-change: transform; transition: background-color var(--duration-standard) var(--ease-standard); }
.switch[data-state="checked"] .thumb { background: var(--control-thumb-on); }
@media (hover: hover) and (pointer: fine) { .switch[data-state="unchecked"]:hover:not(:disabled) .track { background: var(--control-track-hover); } }
.switch:focus-visible { outline: none; }
.label { line-height: var(--leading-body); }
@media (prefers-reduced-motion: reduce) { .track, .track::before, .thumb { transition: none; } }
```

### `registry/components/segmented-control/segmented-control.tsx`

```tsx
"use client";

import { useEffect, useId, useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./segmented-control.module.css";

export interface Segment { value: string; label: string; /** Optional content after the label, such as a badge. */ accessory?: ReactNode; }
export interface SegmentedControlProps {
  options: Segment[];
  value: string;
  onValueChange: (value: string) => void;
  label?: string;
  /** Called when the pointer or focus reaches an option, before it is chosen. Use it to start loading what that option shows. */
  onOptionIntent?: (value: string) => void;
  /** `sm` (default) or `xs`, a compact 24px control for dense UI such as an Edit/Read toggle in a toolbar. */
  size?: "sm" | "xs";
  className?: string;
}

export default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, size = "sm", className }: SegmentedControlProps) {
  const id = useId();
  const reduced = useReducedMotion();
  const track = useRef<HTMLDivElement>(null);

  // When the options are wider than the container, the track scrolls inside itself. Edges fade only on the side with more to see.
  useLayoutEffect(() => {
    const node = track.current;
    if (!node) return;
    const edges = () => {
      const rest = node.scrollWidth - node.clientWidth - node.scrollLeft;
      node.toggleAttribute("data-fade-start", node.scrollLeft > 1);
      node.toggleAttribute("data-fade-end", rest > 1);
    };
    edges();
    node.addEventListener("scroll", edges, { passive: true });
    const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(edges);
    observer?.observe(node);
    return () => { node.removeEventListener("scroll", edges); observer?.disconnect(); };
  }, [options.length]);

  // The selected option is always scrolled fully into view, with a little room so it clears the fade.
  const first = useRef(true);
  useEffect(() => {
    const node = track.current;
    const button = node?.querySelector<HTMLElement>('[aria-pressed="true"]');
    if (!node || !button || node.scrollWidth <= node.clientWidth) { first.current = false; return; }
    const room = 20, start = button.offsetLeft - room, end = button.offsetLeft + button.offsetWidth + room - node.clientWidth;
    const left = node.scrollLeft > start ? start : node.scrollLeft < end ? end : node.scrollLeft;
    if (left !== node.scrollLeft) node.scrollTo({ left: Math.max(0, left), behavior: first.current || reduced ? "auto" : "smooth" });
    first.current = false;
  }, [value, reduced]);

  // Arrow keys, Home and End move the selection like a tab list; only the selected option is a tab stop.
  const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));
  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    const last = options.length - 1;
    const target = event.key === "ArrowRight" || event.key === "ArrowDown" ? (selectedIndex === last ? 0 : selectedIndex + 1)
      : event.key === "ArrowLeft" || event.key === "ArrowUp" ? (selectedIndex === 0 ? last : selectedIndex - 1)
        : event.key === "Home" ? 0 : event.key === "End" ? last : -1;
    if (target < 0 || !options[target]) return;
    event.preventDefault();
    onValueChange(options[target].value);
    track.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(options[target].value)}"]`)?.focus({ preventScroll: true });
  };

  return <div className={`${styles.root} ${className ?? ""}`} role="group" aria-label={label} data-size={size}>
    <LayoutGroup id={id}><motion.div ref={track} layoutScroll className={styles.track}>
      {options.map((option, index) => <button key={option.value} id={`${id}-${option.value}`} className={styles.button} type="button" data-value={option.value} aria-pressed={value === option.value} tabIndex={index === selectedIndex ? 0 : -1} onClick={() => onValueChange(option.value)} onKeyDown={onKeyDown} onPointerEnter={onOptionIntent ? () => onOptionIntent(option.value) : undefined} onFocus={onOptionIntent ? () => onOptionIntent(option.value) : undefined}>
        {value === option.value && <motion.span className={styles.selection} layoutId="selection" layoutDependency={value} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden="true" />}
        <span className={styles.label}>{option.label}{option.accessory}</span>
      </button>)}
    </motion.div></LayoutGroup>
  </div>;
}
```

### `registry/components/segmented-control/segmented-control.module.css`

```css
/* The frame can shrink below its content inside flex and grid parents; the track then scrolls inside it. */
.root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }
/* The track owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */
.track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: var(--space-hair); overflow-x: auto; overscroll-behavior-x: contain; padding: var(--space-tight); border-radius: inherit; corner-shape: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); }
.track::-webkit-scrollbar { display: none; }
.track[data-fade-start] { --fade-start: 20px; }
.track[data-fade-end] { --fade-end: 20px; }
/* Items never scale or change weight; only the highlight travels and the label color follows it. */
.button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - var(--space-tight)); padding: 0 var(--space-group); background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .button:hover { color: var(--foreground); } }
.button:active, .button[aria-pressed="true"] { color: var(--foreground); }
.selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; corner-shape: inherit; background: var(--surface); box-shadow: var(--shadow-resting); }
.label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: var(--space-related); }
/* Compact: 24px segments in a thinner track, at the small type step. */
.root[data-size="xs"] .track { padding: var(--space-hair); }
.root[data-size="xs"] .button { min-height: var(--control-height-xs); padding: 0 var(--space-related); font-size: var(--text-xs); }
@media (max-width: 380px) { .button { padding: 0 var(--space-group); } }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
```

### `registry/components/button/button.tsx`

```tsx
"use client";

import { forwardRef, isValidElement, useCallback, useEffect, useLayoutEffect, useRef } from "react";
import type { ButtonHTMLAttributes, ReactNode, Ref, RefObject } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion } from "motion/react";
import type { TargetAndTransition, Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./button.module.css";

/** `soft` is filled and borderless, for controls in a bar (experimental). */
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "soft";
/** `xs` (24px) is for dense UI such as tree rows and toolbars, usually icon-only with an aria-label. */
export type ButtonSize = "xs" | "sm" | "md" | "lg";

export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onDrag" | "onDragEnd" | "onDragStart" | "onAnimationStart"> {
  /** The look: `primary` (default) for the main action, `secondary` bordered, `ghost` quiet, `danger` destructive, `soft` filled for bars. */
  variant?: ButtonVariant;
  /** Height: `xs` 24px, `sm` 36px, `md` 44px (default), `lg` 50px. */
  size?: ButtonSize;
  /** Shows a spinner in place of the label and sets aria-busy, keeping the button's width. */
  loading?: boolean;
}

/** Icon buttons press a little deeper, wide buttons a little less, so every size reads as the same push. */
const pressVariants: Variants = {
  pressed: (button: RefObject<HTMLButtonElement | null>) => {
    const width = button.current?.offsetWidth ?? 0;
    return { scale: width > 220 ? .985 : width && width <= 48 ? .96 : .97, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } };
  },
};

const rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
/** Text rises about .3em out of a soft blur; the outgoing label lifts away a little faster. */
const textIn: TargetAndTransition = { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)` };
const textOut: TargetAndTransition = { opacity: 0, y: -3, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
const iconOut: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } };
const fadeIn: TargetAndTransition = { ...rest, opacity: 0 };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** Scale rides the spring; opacity and blur tween so blur never overshoots below zero. */
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] }, filter: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } } as const;

/** A key for the label content: text plus element names, so a new label or icon crossfades while prop-only updates stay in place. */
function labelKey(node: ReactNode): string {
  if (node == null || typeof node === "boolean") return "";
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") return String(node);
  if (Array.isArray(node)) return node.map(labelKey).join("");
  if (!isValidElement(node)) return "";
  const type = node.type as string | { displayName?: string; name?: string };
  return `<${typeof type === "string" ? type : type?.displayName ?? type?.name ?? ""}>${labelKey((node.props as { children?: ReactNode }).children)}`;
}

/** Springs the slot to the natural width of the incoming label when it changes, so a new label never snaps the button's size.
 *  The outgoing label is popped out of flow at once, so it never holds the old width. Other resizes (a late web font, a parent reflow) jump straight to the new width, so nothing wobbles on first paint. */
function useMorphWidth(content: RefObject<HTMLElement | null>, key: string, reduced: boolean) {
  const width = useMotionValue<number | "auto">("auto");
  const lastKey = useRef(key), armedUntil = useRef(0);
  useLayoutEffect(() => {
    if (lastKey.current === key) return;
    lastKey.current = key;
    armedUntil.current = performance.now() + 700;
  }, [key]);
  useEffect(() => {
    const node = content.current, slot = node?.parentElement;
    if (!node || !slot || typeof ResizeObserver === "undefined") return;
    let measured = false;
    const observer = new ResizeObserver(([entry]) => {
      if (!entry) return;
      const next = entry.contentRect.width;
      if (!next || !measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; width.jump(next || "auto"); delete slot.dataset.morphing; return; }
      slot.dataset.morphing = "";
      animate(width, next, { ...motionTokens.spring.morph, onComplete: () => { delete slot.dataset.morphing; } });
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [content, reduced, width]);
  return width;
}

function LabelPhase({ children, icon, reduced, ref }: { children: ReactNode; icon: boolean; reduced: boolean; ref?: Ref<HTMLSpanElement> }) {
  const present = useIsPresent();
  return <motion.span ref={ref} className={styles.labelPhase} aria-hidden={present ? undefined : true} initial={reduced ? fadeIn : icon ? iconIn : textIn} animate={rest} exit={reduced ? fadeOut : icon ? iconOut : textOut} transition={reduced ? { duration: motionTokens.duration.instant } : icon ? iconEnter : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{children}</motion.span>;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { className, variant = "primary", size = "md", loading = false, disabled, children, onClick, ...props },
  ref,
) {
  const reduceMotion = useReducedMotion() ?? false;
  const buttonRef = useRef<HTMLButtonElement | null>(null);
  const contentRef = useRef<HTMLSpanElement>(null);
  const key = labelKey(children);
  const width = useMorphWidth(contentRef, key, reduceMotion);
  const setRefs = useCallback((node: HTMLButtonElement | null) => {
    buttonRef.current = node;
    if (typeof ref === "function") ref(node); else if (ref) ref.current = node;
  }, [ref]);
  // A trigger that anchors a menu, popover, or dialog keeps its rect still while pressed, so the layer never measures a scaled anchor.
  // Radix triggers (asChild) pass aria-haspopup or data-state through; the stylesheet repeats the guard.
  const popup = props["aria-haspopup"];
  const anchorsLayer = (popup !== undefined && popup !== false && popup !== "false") || props.role === "combobox" || (props as Record<string, unknown>)["data-state"] !== undefined;
  const inert = disabled || loading || props["aria-disabled"] === true || props["aria-disabled"] === "true";
  const classes = [styles.button, styles[variant], styles[size], className].filter(Boolean).join(" ");
  // An icon with no text: square at the compact size.
  const iconOnly = !/\S/.test(key.replace(/<[^>]*>/g, ""));

  return (
    <motion.button
      ref={setRefs}
      tabIndex={props.tabIndex ?? 0}
      className={classes}
      data-variant={variant}
      data-icon-only={iconOnly || undefined}
      disabled={disabled}
      aria-busy={loading || undefined}
      custom={buttonRef}
      variants={pressVariants}
      whileTap={reduceMotion || anchorsLayer || inert ? undefined : "pressed"}
      transition={motionTokens.spring.snappy}
      {...props}
      // Loading keeps the button focusable (a disabled button would drop keyboard focus mid-action) and swallows presses instead.
      aria-disabled={loading || props["aria-disabled"] || undefined}
      onClick={loading ? event => event.preventDefault() : onClick}
    >
      <AnimatePresence initial={false}>
        {loading ? <motion.span key="loader" className={styles.loader} aria-hidden="true" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={reduceMotion ? fadeOut : { ...iconOut, scale: .8 }} transition={reduceMotion ? { duration: motionTokens.duration.instant } : iconEnter}><span className={styles.spinner} /></motion.span> : null}
      </AnimatePresence>
      <motion.span className={[styles.labelSlot, loading ? styles.loadingLabel : ""].filter(Boolean).join(" ")} style={{ width }}>
        <span ref={contentRef} className={styles.labelContent}>
          <AnimatePresence mode="popLayout" initial={false}>
            <LabelPhase key={key} icon={!/\S/.test(key.replace(/<[^>]*>/g, ""))} reduced={reduceMotion}>{children}</LabelPhase>
          </AnimatePresence>
        </span>
      </motion.span>
    </motion.button>
  );
});

Button.displayName = "Button";

export default Button;
```

### `registry/components/button/button.module.css`

```css
/* Press scale is driven by Motion (whileTap) alone, so transform never gets a CSS transition here. */
.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-related); border: 1px solid transparent; border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: 0 var(--space-inset); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
.button:disabled { cursor: not-allowed; opacity: .52; }
.button[aria-busy="true"] { cursor: progress; }
/* Anchors of floating layers never scale: Radix opens on pointerdown and would measure a shrunken trigger. */
.button:is([aria-haspopup]:not([aria-haspopup="false"]), [data-state], [role="combobox"]) { transform: none !important; }
/* The slot's width follows the incoming label on a spring. The label stays centered and may overflow evenly while the slot catches up;
   the outgoing label pops out of flow against the slot itself, so it keeps its place instead of following the new content box. */
.labelSlot { position: relative; display: inline-flex; min-width: 0; align-items: center; justify-content: center; transition: opacity var(--duration-fast) var(--ease-standard); }
.labelSlot[data-morphing] { clip-path: inset(-50% calc(var(--space-group) * -1)); }
.labelContent { display: inline-flex; flex: none; align-items: center; }
.labelPhase { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-related); white-space: nowrap; }
.labelPhase svg { flex: none; }
.primary { color: var(--primary-foreground); background: var(--primary); border-color: var(--primary); }
.secondary { color: var(--foreground); background: var(--surface); border-color: var(--border); }
.ghost { color: var(--text-secondary); background: transparent; border-color: transparent; }
.danger { color: var(--danger); background: var(--surface); border-color: var(--border); }
/* Filled and borderless, for controls that live in a bar (a search trigger, a toolbar, a switcher). Place it on --background or
   --surface; on a --surface-muted panel it has no fill to show. */
.soft { color: var(--foreground); background: var(--surface-muted); border-color: transparent; }
/* A disabled outlined button keeps its shape and fades only its label, so it still reads as a button that is off, not as plain text. */
.secondary:disabled, .danger:disabled { opacity: 1; color: var(--text-muted); background: var(--surface-muted); border-color: var(--border); }
.danger:disabled { color: color-mix(in oklab, var(--danger) 55%, var(--surface-muted)); }
.ghost:disabled, .soft:disabled { opacity: 1; color: var(--text-muted); }
@media (hover: hover) and (pointer: fine) {
  .primary:hover:not(:disabled) { opacity: .91; box-shadow: var(--shadow-resting); }
  .secondary:hover:not(:disabled) { background: var(--surface-muted); box-shadow: var(--shadow-resting); }
  .ghost:hover:not(:disabled) { color: var(--foreground); background: var(--surface-muted); }
  .danger:hover:not(:disabled) { border-color: var(--danger); background: var(--surface-muted); }
  .soft:hover:not(:disabled) { background: var(--surface-pressed); }
}
/* Color feedback on press, so touch and menu triggers (which never scale) still answer the finger. */
.primary:active:not(:disabled) { opacity: .84; }
.secondary:active:not(:disabled), .ghost:active:not(:disabled) { color: var(--foreground); background: var(--surface-muted); }
.danger:active:not(:disabled) { border-color: var(--danger); background: var(--surface-muted); }
.soft:active:not(:disabled) { background: var(--surface-pressed); }
.xs { min-height: var(--control-height-xs); gap: var(--space-tight); padding-inline: var(--space-related); font-size: var(--text-xs); }
/* A compact icon-only button is a square, with the icon at its usual 16px. */
.xs[data-icon-only] { min-width: var(--control-height-xs); padding-inline: 0; }
.xs svg { width: 16px; height: 16px; }
.sm { min-height: var(--control-height-sm); padding-inline: var(--space-group); font-size: var(--text-sm); }
.md { min-height: var(--control-height-md); }
.lg { min-height: var(--control-height-lg); padding-inline: var(--space-comfort); font-size: var(--text-sm); }
.loader { position: absolute; left: 50%; top: 50%; display: grid; width: var(--space-inset); height: var(--space-inset); margin: calc(var(--space-inset) / -2); }
.spinner { border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; corner-shape: round; animation: spin .7s linear infinite; }
.loadingLabel { opacity: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } .labelSlot { transition: opacity var(--duration-instant) linear; } .spinner { animation-duration: .01ms; animation-iteration-count: 1; } }
```
