# CTA section

> A call to action as a centered closing section, a split beside a setup card that completes itself, or a dismissible banner.

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/cta-section).

- Page, with a live preview: https://shadcn.exchange/r/silicon-ui/cta-section
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/silicon-ui/cta-section-light-a655443da495d8ca.png
- Registry item: https://ui.teamofsilicons.com/r/cta-section.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/cta-section.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: `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`

## Notes from the registry

Docs and live preview: https://ui.teamofsilicons.com/cta-section

## Files

### `registry/blocks/cta-section/cta-section.tsx`

```tsx
"use client";

import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion } from "motion/react";
import { ArrowRight, Check, X } from "lucide-react";
import { Button } from "../../button/button";
import SegmentedControl from "../../segmented-control/segmented-control";
import { motionTokens } from "../../lib/motion-tokens";
import { ctaCopy, ctaFaces, ctaSetup, type CtaAction } from "./cta-section-data";
import styles from "./cta-section.module.css";

export type { CtaAction } from "./cta-section-data";
export type CtaVariant = "centered" | "split" | "banner";

export interface CtaSectionProps {
  /** `centered` is a closing section, `split` sits beside a product visual, `banner` is one dismissible line. */
  variant?: CtaVariant;
  title?: string;
  description?: string;
  primaryAction?: CtaAction;
  /** Not shown in the banner variant. Pass null to hide it. */
  secondaryAction?: CtaAction | null;
  /** Small print under the actions in the centered variant, such as "No credit card required". */
  note?: string;
  /** Faces beside the note in the centered variant. Pass an empty array to hide them. */
  faces?: string[];
  /** Short benefit lines in the split variant. */
  points?: string[];
  /** Replaces the split variant's setup card. */
  visual?: ReactNode;
  /** Banner only: shows a dismiss button and calls this after it closes. */
  onDismiss?: () => void;
  className?: string;
}

type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;

/** A link, a callback, or (with neither) a button that confirms in place, so previews always answer a click. */
function Action({ action, variant, size = "lg", arrow }: { action: CtaAction; variant: "primary" | "secondary"; size?: "sm" | "md" | "lg"; arrow?: boolean }) {
  const [confirmed, setConfirmed] = useState(false);
  if (action.href) {
    return <a className={styles.linkAction} data-variant={variant} data-size={size} href={action.href}>
      {action.label}{arrow && <ArrowRight className={styles.arrow} size={16} strokeWidth={2} aria-hidden="true" />}
    </a>;
  }
  const simulated = !action.onClick;
  return <Button
    className={styles.action}
    variant={variant}
    size={size}
    aria-live={simulated ? "polite" : undefined}
    onClick={() => { if (action.onClick) action.onClick(); else setConfirmed(value => !value); }}
  >
    {confirmed
      ? <><Check size={16} strokeWidth={2.25} aria-hidden="true" />{action.confirmedLabel ?? action.label}</>
      : <>{action.label}{arrow && <ArrowRight className={styles.arrow} size={16} strokeWidth={2} aria-hidden="true" />}</>}
  </Button>;
}

/** A setup card that ticks through its steps once it scrolls into view. */
function SetupVisual({ reduced }: { reduced: boolean }) {
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: .5 });
  const total = ctaSetup.steps.length;
  const [step, setStep] = useState(0);
  const shown = reduced ? total : step;

  useEffect(() => {
    if (!inView || reduced) return;
    const timers = ctaSetup.steps.map((_, index) => window.setTimeout(() => setStep(index + 1), 520 + index * 620));
    return () => timers.forEach(window.clearTimeout);
  }, [inView, reduced]);

  return <div ref={ref} className={styles.setup} aria-hidden="true" data-done={shown === total ? "" : undefined}>
    <div className={styles.setupHead}>
      <span className={styles.workspaceMark}>{ctaSetup.workspace.charAt(0)}</span>
      <span className={styles.setupTitle}>{ctaSetup.workspace}</span>
      <span className={styles.setupStatus}>
        <span data-shown={shown < total ? "" : undefined}>Setting up</span>
        <span data-shown={shown === total ? "" : undefined}>Ready</span>
      </span>
    </div>
    <div className={styles.progress}><span style={{ transform: `scaleX(${shown / total})` }} /></div>
    <ol className={styles.steps}>
      {ctaSetup.steps.map((label, index) => {
        const done = index < shown;
        return <li key={label} className={styles.step} data-done={done ? "" : undefined} data-active={index === shown ? "" : undefined}>
          <span className={styles.stepMark}><Check size={12} strokeWidth={2.75} /></span>
          <span className={styles.stepLabel}>{label}</span>
          {index === total - 1 && <span className={styles.stepFaces}>
            {/* eslint-disable-next-line @next/next/no-img-element */}
            {ctaSetup.faces.map((src, face) => <img key={src} src={src} alt="" width={22} height={22} style={{ transitionDelay: `${face * 60}ms` }} />)}
          </span>}
        </li>;
      })}
    </ol>
  </div>;
}

function Faces({ faces }: { faces: string[] }) {
  if (!faces.length) return null;
  return <span className={styles.faces} aria-hidden="true">
    {/* eslint-disable-next-line @next/next/no-img-element */}
    {faces.slice(0, 4).map(src => <img key={src} src={src} alt="" width={26} height={26} />)}
  </span>;
}

/**
 * A call to action in three shapes: a centered closing section, a split layout beside a setup card that completes
 * itself in view, and a compact banner that closes its gap when dismissed.
 */
export const CtaSection = forwardRef<HTMLElement, CtaSectionProps>(function CtaSection({
  variant = "centered",
  title,
  description,
  primaryAction,
  secondaryAction,
  note,
  faces = ctaFaces,
  points,
  visual,
  onDismiss,
  className,
}, ref) {
  const id = useId();
  const reduced = !!useReducedMotion();
  const [open, setOpen] = useState(true);
  const copy = ctaCopy[variant];
  const heading = title ?? copy.title;
  const text = description ?? copy.description;
  const primary = primaryAction ?? copy.primary;
  const secondary = secondaryAction === null ? null : secondaryAction ?? ("secondary" in copy ? copy.secondary : undefined);
  const classes = [styles.cta, className].filter(Boolean).join(" ");

  if (variant === "banner") {
    return <section ref={ref} className={classes} data-variant="banner" aria-labelledby={`${id}-title`}>
      <AnimatePresence initial={false} onExitComplete={onDismiss}>
        {open && <motion.div
          key="banner"
          className={styles.bannerWrap}
          exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, height: 0, transition: { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: standard } } }}
        >
          <div className={styles.bannerPad}><div className={styles.banner}>
            <p className={styles.bannerText}>
              <strong id={`${id}-title`}>{heading}</strong>
              <span>{text}</span>
            </p>
            <div className={styles.bannerActions}>
              <Action action={primary} variant="primary" size="sm" arrow />
              {onDismiss !== undefined && <button type="button" className={styles.dismiss} aria-label="Dismiss" onClick={() => setOpen(false)}>
                <X size={16} strokeWidth={1.75} aria-hidden="true" />
              </button>}
            </div>
          </div></div>
        </motion.div>}
      </AnimatePresence>
    </section>;
  }

  if (variant === "split") {
    const list = points ?? ctaCopy.split.points;
    return <section ref={ref} className={classes} data-variant="split" aria-labelledby={`${id}-title`}>
      <div className={styles.split}>
        <div className={styles.splitCopy}>
          <h2 id={`${id}-title`} className={styles.title}>{heading}</h2>
          <p className={styles.description}>{text}</p>
          {list.length > 0 && <ul className={styles.points}>
            {list.map(point => <li key={point}><Check size={16} strokeWidth={2} aria-hidden="true" />{point}</li>)}
          </ul>}
          <div className={styles.actions}>
            <Action action={primary} variant="primary" arrow />
            {secondary && <Action action={secondary} variant="secondary" />}
          </div>
        </div>
        <motion.div
          className={styles.stage}
          initial={reduced ? false : { opacity: 0, y: 24 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: .35 }}
          transition={{ duration: motionTokens.duration.considered, ease: enter }}
        >
          {visual ?? <SetupVisual reduced={reduced} />}
        </motion.div>
      </div>
    </section>;
  }

  const small = note ?? ctaCopy.centered.note;
  return <section ref={ref} className={classes} data-variant="centered" aria-labelledby={`${id}-title`}>
    <div className={styles.centeredWrap}>
      <div className={styles.centered}>
        <h2 id={`${id}-title`} className={styles.title}>{heading}</h2>
        <p className={styles.description}>{text}</p>
        <div className={styles.actions}>
          <Action action={primary} variant="primary" arrow />
          {secondary && <Action action={secondary} variant="secondary" />}
        </div>
        {(small || faces.length > 0) && <p className={styles.note}><Faces faces={faces} />{small}</p>}
      </div>
    </div>
  </section>;
});

CtaSection.displayName = "CtaSection";

const variantOptions = [{ value: "centered", label: "Centered" }, { value: "split", label: "Split" }, { value: "banner", label: "Banner" }];

/** Springs the frame to its content's height after a discrete change (a new layout, a dismissed banner), so the preview never snaps.
 *  Other resizes, such as the banner's own collapse or a parent reflow, follow at once so nothing lags. */
function useFrameHeight(key: string, reduced: boolean) {
  const content = useRef<HTMLDivElement>(null);
  const height = 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;
    if (!node || typeof ResizeObserver === "undefined") return;
    let measured = false;
    const observer = new ResizeObserver(() => {
      const next = node.offsetHeight;
      if (!measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; height.jump(next || "auto"); return; }
      animate(height, next, motionTokens.spring.smooth);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [height, reduced]);
  return { content, height };
}

/** Preview: all three shapes. Buttons confirm in place; nothing is sent. */
export function CtaSectionBlock() {
  const reduced = !!useReducedMotion();
  const [variant, setVariant] = useState<CtaVariant>("centered");
  const [dismissed, setDismissed] = useState(false);
  const showRestore = variant === "banner" && dismissed;
  const view = showRestore ? "restore" : variant;
  const { content, height } = useFrameHeight(view, reduced);
  // The first layout appears as is; later layouts fade in while the frame springs to their height.
  const [changed, setChanged] = useState(false);
  return <div className={styles.preview}>
    <SegmentedControl label="Call to action layout" options={variantOptions} value={variant} onValueChange={next => { setChanged(true); setVariant(next as CtaVariant); setDismissed(false); }} />
    <motion.div className={styles.frame} style={{ height }}>
      <div ref={content}>
        <motion.div key={view} initial={reduced || !changed ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: motionTokens.duration.standard, ease: standard }}>
          {showRestore
            ? <div className={styles.restore}><Button variant="secondary" size="sm" onClick={() => setDismissed(false)}>Show the banner again</Button></div>
            : <CtaSection variant={variant} onDismiss={variant === "banner" ? () => { setChanged(true); setDismissed(true); } : undefined} />}
        </motion.div>
      </div>
    </motion.div>
  </div>;
}

export default CtaSectionBlock;
```

### `registry/blocks/cta-section/cta-section-data.ts`

```ts
import { avatar, type PersonId } from "../../lib/media";

export interface CtaAction {
  label: string;
  /** Renders a link. */
  href?: string;
  onClick?: () => void;
  /** With no href or onClick, the button confirms in place with this label, which suits previews. */
  confirmedLabel?: string;
}

export const ctaCopy = {
  centered: {
    title: "Start building in minutes",
    description: "Set up a workspace, invite your team, and ship the first project this week. Free for 14 days.",
    primary: { label: "Start free trial", confirmedLabel: "Trial started" },
    secondary: { label: "Talk to sales", confirmedLabel: "We'll email you" },
    note: "No credit card required",
  },
  split: {
    title: "Your team, set up before lunch",
    description: "Import your projects, bring everyone in, and keep working the way you already do.",
    primary: { label: "Create a workspace", confirmedLabel: "Workspace created" },
    secondary: { label: "Book a demo", confirmedLabel: "Demo requested" },
    points: ["Import from any tracker in one step", "Single sign-on and audit logs included", "Cancel anytime from settings"],
  },
  banner: {
    title: "Workflows are here",
    description: "Automate handoffs between teams with no code.",
    primary: { label: "See how it works", confirmedLabel: "Opened" },
  },
} satisfies Record<string, { title: string; description: string; primary: CtaAction; secondary?: CtaAction; note?: string; points?: string[] }>;

/** Faces for the social proof line in the centered variant. */
export const ctaFaces = (["sofia-ramirez", "nathan-cole", "chloe-nguyen", "tyler-hayes"] as PersonId[]).map(id => avatar(id));

/** Sample content for the split variant's setup card. */
export const ctaSetup = {
  workspace: "Northwind",
  steps: ["Create workspace", "Import 214 issues", "Invite 4 teammates"],
  faces: (["emma-collins", "marcus-johnson", "jasmine-brooks", "daniel-kim"] as PersonId[]).map(id => avatar(id)),
};
```

### `lib/media.ts`

```ts
/**
 * Shared demo media. Files live in `public/media/`, credits in `public/media/CREDITS.md`.
 * Portraits are 400 px squares; photos are 1600 px on the long side.
 */

export interface MediaPerson {
  id: string;
  name: string;
  role?: string;
  src: string;
}

export interface MediaPhoto {
  id: string;
  alt: string;
  src: string;
  width: number;
  height: number;
}

export const people = [
  { id: "emma-collins", name: "Emma Collins", role: "Product designer", src: "/media/people/emma-collins.jpg" },
  { id: "marcus-johnson", name: "Marcus Johnson", role: "Frontend engineer", src: "/media/people/marcus-johnson.jpg" },
  { id: "jasmine-brooks", name: "Jasmine Brooks", role: "Design lead", src: "/media/people/jasmine-brooks.jpg" },
  { id: "olivia-bennett", name: "Olivia Bennett", role: "Platform engineer", src: "/media/people/olivia-bennett.jpg" },
  { id: "sofia-ramirez", name: "Sofia Ramirez", role: "Operations lead", src: "/media/people/sofia-ramirez.jpg" },
  { id: "ryan-sullivan", name: "Ryan Sullivan", role: "Account executive", src: "/media/people/ryan-sullivan.jpg" },
  { id: "hannah-walsh", name: "Hannah Walsh", role: "Customer success", src: "/media/people/hannah-walsh.jpg" },
  { id: "chloe-nguyen", name: "Chloe Nguyen", role: "Data analyst", src: "/media/people/chloe-nguyen.jpg" },
  { id: "ava-mitchell", name: "Ava Mitchell", role: "Marketing manager", src: "/media/people/ava-mitchell.jpg" },
  { id: "daniel-kim", name: "Daniel Kim", role: "Backend engineer", src: "/media/people/daniel-kim.jpg" },
  { id: "jordan-reyes", name: "Jordan Reyes", role: "Support specialist", src: "/media/people/jordan-reyes.jpg" },
  { id: "mateo-alvarez", name: "Mateo Alvarez", role: "Mobile engineer", src: "/media/people/mateo-alvarez.jpg" },
  { id: "tyler-hayes", name: "Tyler Hayes", role: "Sales lead", src: "/media/people/tyler-hayes.jpg" },
  { id: "andre-williams", name: "Andre Williams", role: "Finance partner", src: "/media/people/andre-williams.jpg" },
  { id: "nathan-cole", name: "Nathan Cole", role: "Engineering manager", src: "/media/people/nathan-cole.jpg" },
  { id: "diane-foster", name: "Diane Foster", role: "Chief operating officer", src: "/media/people/diane-foster.jpg" },
] as const satisfies readonly MediaPerson[];

export const photos = [
  { id: "lounge-chair", alt: "A woven oak lounge chair with a sheepskin and linen cushion on a concrete floor", src: "/media/photos/lounge-chair.jpg", width: 1280, height: 1600 },
  { id: "table-lamp", alt: "A white mushroom table lamp glowing beside books and a small vase", src: "/media/photos/table-lamp.jpg", width: 1600, height: 900 },
  { id: "linen-throw", alt: "Folded natural linen throws with fringed edges in soft window light", src: "/media/photos/linen-throw.jpg", width: 1067, height: 1600 },
  { id: "glass-carafe", alt: "A hand pouring water from a ribbed glass carafe into tumblers", src: "/media/photos/glass-carafe.jpg", width: 1280, height: 1600 },
  { id: "stoneware-cups", alt: "Two speckled stoneware cups with a lid on a pale table", src: "/media/photos/stoneware-cups.jpg", width: 1067, height: 1600 },
  { id: "stacked-bowls", alt: "Two stacked speckled ceramic bowls against a dark wall", src: "/media/photos/stacked-bowls.jpg", width: 1600, height: 1067 },
  { id: "ceramic-lamp", alt: "A sculptural ceramic lamp with a linen shade on a walnut sideboard", src: "/media/photos/ceramic-lamp.jpg", width: 1067, height: 1600 },
  { id: "living-room", alt: "A bright living room with timber beams, arched windows, and cream sofas", src: "/media/photos/living-room.jpg", width: 1200, height: 1600 },
  { id: "sunroom", alt: "A sunroom with a round dining table, plants, and windows on three sides", src: "/media/photos/sunroom.jpg", width: 1600, height: 1067 },
  { id: "home-office", alt: "A home office with a wooden desk and deep green walls", src: "/media/photos/home-office.jpg", width: 1600, height: 1200 },
  { id: "reading-chair", alt: "A grey armchair and ottoman with a knit throw in a dark green room", src: "/media/photos/reading-chair.jpg", width: 1600, height: 900 },
  { id: "bedroom", alt: "A made bed with striped linen pillows against an oak headboard", src: "/media/photos/bedroom.jpg", width: 1067, height: 1600 },
  { id: "restaurant", alt: "A warm restaurant dining room with woven pendant lights and a tree", src: "/media/photos/restaurant.jpg", width: 1067, height: 1600 },
  { id: "wine-bar", alt: "A glass carafe of red wine on a bar table in low evening light", src: "/media/photos/wine-bar.jpg", width: 1600, height: 1067 },
  { id: "concert-hall", alt: "Curved stainless steel panels of the Walt Disney Concert Hall against a blue sky", src: "/media/photos/concert-hall.jpg", width: 1600, height: 1143 },
  { id: "curved-facade", alt: "A white tiled building facade with curved balconies", src: "/media/photos/curved-facade.jpg", width: 1600, height: 1067 },
  { id: "pool-house", alt: "A modern glass house beside a long pool under a clear sky", src: "/media/photos/pool-house.jpg", width: 1600, height: 900 },
  { id: "terracotta-waves", alt: "Wavy terracotta walls rising toward a blue sky", src: "/media/photos/terracotta-waves.jpg", width: 1067, height: 1600 },
  { id: "mountain-ridges", alt: "Layered mountain ridges under a warm evening sky", src: "/media/photos/mountain-ridges.jpg", width: 1600, height: 1068 },
  { id: "alpine-lake", alt: "A calm alpine lake reflecting a rocky peak at golden hour", src: "/media/photos/alpine-lake.jpg", width: 1067, height: 1600 },
  { id: "coastline", alt: "A long coastline with waves rolling onto a beach below green cliffs", src: "/media/photos/coastline.jpg", width: 1200, height: 1600 },
  { id: "sea-at-dusk", alt: "A calm sea at dusk with a low island on the horizon", src: "/media/photos/sea-at-dusk.jpg", width: 1067, height: 1600 },
  { id: "lisbon-tram", alt: "A yellow tram on a street lined with historic buildings in Lisbon", src: "/media/photos/lisbon-tram.jpg", width: 1600, height: 1064 },
  { id: "lisbon-bridge", alt: "The 25 de Abril Bridge crossing the Tagus in Lisbon", src: "/media/photos/lisbon-bridge.jpg", width: 1600, height: 1166 },
  { id: "lisbon-rooftops", alt: "Terracotta rooftops of Lisbon running down to the river", src: "/media/photos/lisbon-rooftops.jpg", width: 1280, height: 1600 },
  { id: "salmon-dinner", alt: "Seared salmon with a bright herb salsa and a glass of red wine", src: "/media/photos/salmon-dinner.jpg", width: 1067, height: 1600 },
  { id: "chef-plating", alt: "A chef spooning sauce onto a plated dish in a dark kitchen", src: "/media/photos/chef-plating.jpg", width: 1600, height: 1600 },
] as const satisfies readonly MediaPhoto[];

export type PersonId = (typeof people)[number]["id"];
export type PhotoId = (typeof photos)[number]["id"];

/** Looks up a person by id. */
export function person(id: PersonId): MediaPerson {
  return people.find(entry => entry.id === id)!;
}

/** Looks up a photo by id. */
export function photo(id: PhotoId): MediaPhoto {
  return photos.find(entry => entry.id === id)!;
}

/** Square 400 px avatar path for a person id, for `src` props. */
export const avatar = (id: PersonId) => person(id).src;

/** 800 × 1000 portrait crop of the same photo, for image-led layouts. */
export const portrait = (id: PersonId) => `/media/people/portrait/${id}.jpg`;

/** The first `count` people, for avatar stacks and lists. */
export const peopleSample = (count: number) => people.slice(0, count);
```

### `registry/blocks/cta-section/cta-section.module.css`

```css
.cta { container: cta / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.cta *, .cta *::before, .cta *::after { box-sizing: border-box; }

.title { margin: 0; font-family: var(--font-display); font-size: clamp(var(--text-2xl), var(--text-base) + 3.4cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { margin: 0; color: var(--text-secondary); font-size: var(--text-lg); line-height: var(--leading-body); text-wrap: pretty; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-group); }

/* Actions: the arrow leans forward on hover; links match the button shapes. */
.arrow { transition: transform var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .action:hover .arrow, .linkAction:hover .arrow { transform: translateX(3px); } }
.linkAction { display: inline-flex; height: var(--control-height-lg); 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-comfort); font-size: var(--text-sm); font-weight: 500; text-decoration: none; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.linkAction[data-size="sm"] { height: var(--control-height-sm); padding: 0 var(--space-inset); }
.linkAction[data-variant="primary"] { background: var(--foreground); color: var(--background); }
.linkAction[data-variant="secondary"] { border-color: var(--border); background: var(--surface); color: var(--foreground); }
.linkAction:active { transform: scale(.97); }

/* Centered: a closing section on one quiet surface. */
.centeredWrap { max-width: 1120px; margin: 0 auto; padding: var(--space-stage) var(--space-block); }
.centered { display: grid; justify-items: center; gap: var(--space-inset); border: 1px solid var(--border); border-radius: var(--radius-overlay); corner-shape: var(--corner-shape); padding: var(--space-screen) var(--space-block); background: color-mix(in oklab, var(--surface-muted) 45%, var(--surface)); text-align: center; }
.centered .title { max-width: 18ch; }
.centered .description { max-width: 34rem; }
.centered .actions { justify-content: center; margin-top: var(--space-inset); }
.note { display: inline-flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: var(--space-group); margin: var(--space-group) 0 0; color: var(--text-muted); font-size: var(--text-sm); }
.faces { display: flex; padding-left: var(--space-related); }
.faces img { display: block; width: 26px; height: 26px; margin-left: calc(var(--space-related) * -1); border: 2px solid var(--surface); border-radius: 50%; corner-shape: round; object-fit: cover; }

/* Split: copy beside a setup card that completes itself. */
.split { display: grid; max-width: 1120px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: var(--space-stage); margin: 0 auto; padding: var(--space-screen) var(--space-block); }
.splitCopy { display: grid; justify-items: start; gap: var(--space-inset); }
.splitCopy .actions { margin-top: var(--space-inset); }
.points { display: grid; gap: var(--space-related); margin: var(--space-related) 0 0; padding: 0; list-style: none; }
.points li { display: flex; align-items: center; gap: var(--space-group); color: var(--text-secondary); font-size: var(--text-sm); }
.points svg { flex: none; color: var(--success); }
.stage { display: grid; min-height: 380px; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-overlay); corner-shape: var(--corner-shape); padding: var(--space-block); background: color-mix(in oklab, var(--surface-muted) 55%, var(--surface)); }

.setup { width: min(100%, 360px); border: 1px solid var(--border); border-radius: var(--radius-container); corner-shape: var(--corner-shape); background: var(--surface-raised); box-shadow: var(--shadow-raised); overflow: hidden; }
.setupHead { display: flex; align-items: center; gap: var(--space-group); padding: var(--space-inset) var(--space-comfort) var(--space-inset); }
.workspaceMark { display: grid; width: 28px; height: 28px; flex: none; place-items: center; border-radius: var(--radius-detail); corner-shape: var(--corner-shape); background: var(--foreground); color: var(--background); font-size: var(--text-sm); font-weight: 500; }
.setupTitle { font-size: var(--text-sm); font-weight: 500; }
.setupStatus { display: inline-grid; margin-left: auto; color: var(--text-muted); font-size: var(--text-xs); justify-items: end; }
.setupStatus > span { grid-area: 1 / 1; opacity: 0; transform: translateY(4px); filter: blur(2px); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-standard) var(--ease-enter), filter var(--duration-fast) var(--ease-standard); }
.setupStatus > span[data-shown] { opacity: 1; transform: none; filter: blur(0); }
.setup[data-done] .setupStatus > span:last-child { color: var(--success); font-weight: 500; }
.progress { height: 2px; margin: 0 var(--space-comfort); border-radius: var(--radius-full); corner-shape: round; background: var(--border); overflow: hidden; }
.progress > span { display: block; height: 100%; background: var(--success); transform-origin: left; transition: transform var(--duration-considered) var(--ease-in-out); }
.steps { display: grid; gap: var(--space-hair); margin: 0; padding: var(--space-group) var(--space-related) var(--space-group); list-style: none; }
.step { display: flex; min-height: 44px; align-items: center; gap: var(--space-group); border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: 0 var(--space-group); color: var(--text-muted); font-size: var(--text-sm); transition: color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
.step[data-active] { background: var(--surface-muted); color: var(--foreground); }
.step[data-done] { color: var(--foreground); }
.stepMark { position: relative; display: grid; width: 20px; height: 20px; flex: none; place-items: center; border: 1.5px dashed var(--border-strong); border-radius: 50%; corner-shape: round; color: transparent; transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
.stepMark svg { transform: scale(.5); opacity: 0; transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
.step[data-active] .stepMark { border-style: solid; border-color: var(--text-muted); }
.step[data-done] .stepMark { border-style: solid; border-color: var(--success); background: var(--success); color: var(--background); }
.step[data-done] .stepMark svg { transform: scale(1); opacity: 1; }
.stepLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stepFaces { display: flex; margin-left: auto; padding-left: var(--space-related); }
.stepFaces img { display: block; width: 22px; height: 22px; margin-left: calc(var(--space-related) * -1); border: 2px solid var(--surface-raised); border-radius: 50%; corner-shape: round; object-fit: cover; opacity: 0; transform: translateX(-6px) scale(.9); transition: opacity var(--duration-standard) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
.step[data-done] .stepFaces img, .step[data-active] .stepFaces img { opacity: 1; transform: none; }

/* Banner: one line that closes its gap when dismissed. */
.bannerWrap { overflow: hidden; }
.bannerPad { max-width: calc(1120px + 2 * var(--space-block)); margin: 0 auto; padding: var(--space-section) var(--space-block); }
.banner { display: flex; align-items: center; justify-content: space-between; gap: var(--space-inset); border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); padding: var(--space-group) var(--space-group) var(--space-group) var(--space-comfort); background: var(--surface); }
.bannerText { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: var(--space-hair) var(--space-related); margin: 0; font-size: var(--text-sm); line-height: var(--leading-body); }
.bannerText strong { font-weight: 500; }
.bannerText span { color: var(--text-secondary); }
.bannerActions { display: flex; flex: none; align-items: center; gap: var(--space-tight); }
.dismiss { display: grid; width: var(--control-height-sm); height: var(--control-height-sm); place-items: center; border: 0; border-radius: 50%; corner-shape: round; background: none; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .dismiss:hover { background: var(--surface-muted); color: var(--foreground); } }
.dismiss:active { transform: scale(.94); }

@container cta (max-width: 860px) {
  .split { grid-template-columns: minmax(0, 1fr); gap: var(--space-page); padding: var(--space-stage) var(--space-block); }
  .stage { min-height: 320px; }
}
@container cta (max-width: 560px) {
  .centeredWrap { padding: var(--space-page) var(--space-inset); }
  .centered { padding: var(--space-stage) var(--space-comfort); border-radius: var(--radius-container); corner-shape: var(--corner-shape); }
  .split { padding: var(--space-page) var(--space-inset); }
  .stage { padding: var(--space-comfort); border-radius: var(--radius-container); corner-shape: var(--corner-shape); }
  .description { font-size: var(--text-base); }
  .actions { width: 100%; flex-direction: column; }
  .actions > * { width: 100%; }
  .bannerPad { padding: var(--space-inset); }
  .banner { align-items: flex-start; flex-direction: column; padding: var(--space-inset); }
  .bannerActions { width: 100%; justify-content: space-between; }
}

.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-inset); }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-control); corner-shape: var(--corner-shape); background: var(--background); }
.restore { display: grid; min-height: 104px; place-items: center; padding: var(--space-section); }

@media (prefers-reduced-motion: reduce) {
  .arrow, .setupStatus > span, .progress > span, .step, .stepMark, .stepMark svg, .stepFaces img { 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; } }
```
