標準体系 / components

Core Components — Anatomy, States & Accessibility

The verdict layer. Each component below ships its anatomy, all interactive states (default · hover · active · focus-visible · disabled · loading · error), and its accessibility contract — then the code. Framework-agnostic HTML/CSS first, React 19 + Tailwind v4 (Base UI / Radix primitives, themed) alongside. Everything consumes the CONTRACT tokens: --color-*, --space-*, --radius-*, --text-*, --shadow-*, --dur-*, --ease-*. Never hardcode a hex, px, or ms.

A component is not done when it looks right — it is done when its states and a11y contract are specified. Pinterest Gestalt's bar: visually accessible (4.5:1 text / 3:1 UI), screen-reader compatible (role + name + ARIA), navigable (full keyboard, visible focus), understandable (predictable, clear errors). Build on native semantics (<button>, <label>); ARIA only fills gaps.


0. The shared rules (apply to every component)

Rule Why Token / mechanism
Focus ring is non-negotiable WCAG 2.4.7 + 1.4.11 (3:1 non-text) :focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px }
44px min target for primary controls WCAG 2.5.5/2.5.8, Apple HIG min-block-size on buttons/inputs
Depth = hairline + surface ladder, not heavy shadows Linear/Raycast/Vercel taste 1px solid var(--color-border) over --shadow-*
Motion communicates state, ≤ 250ms, transform/opacity only Stripe rule; GPU-cheap --dur-fast + --ease-out; honor prefers-reduced-motion
Never signal by color alone WCAG 1.4.1 pair color with icon + text
disabled ≠ removed AT must still announce use disabled / aria-disabled, not display:none
/* Drop this once, globally. Every interactive component inherits the ring + reduced-motion. */
:where(button, a, input, select, textarea, [tabindex]):focus-visible {
  outline: 2px solid var(--color-ring);
  outline-offset: 2px;
  border-radius: inherit;
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

React 19 + Tailwind v4 setup — wire CONTRACT tokens into the theme once, then components read utilities and the raw vars. @theme inline keeps tokens out of the cascade where utilities should not re-emit them; the color vars stay in plain :root / .dark so dark mode cascades.

/* app.css */
@import "tailwindcss";
@theme inline {
  /* Bridge the CONTRACT tokens (declared in :root/.dark, outside @theme) into Tailwind
     utilities. `inline` emits the var() reference into utilities, so it resolves to the
     cascading runtime value (and dark mode just works) — this self-map is intentional. */
  --color-bg: var(--color-bg);             /* re-expose CONTRACT vars as Tailwind utilities */
  --color-surface: var(--color-surface);
  --color-fg: var(--color-fg);
  --color-accent: var(--color-accent);
  --color-ring: var(--color-ring);
  --radius-md: var(--radius-md);
  --ease-out: var(--ease-out);
}
// lib/cn.ts — the one helper every shadcn-style component uses
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

1. Button

The most-used interactive primitive. One dominant variant (accent), sharp secondary/ghost, and a semantic danger. Restraint over a rainbow of variants.

Anatomy: [ optional leading icon ] · label · [ optional trailing icon ] inside a native <button>. Loading swaps a spinner in for the leading slot; label stays to preserve width.

States & contract

State Visual Implementation
default accent fill, --color-accent-fg text background: var(--color-accent)
hover darken to --color-accent-hover :hover
active scale 0.98, no color shift :active { transform: scale(.98) }
focus-visible 2px --color-ring, 2px offset global :focus-visible
disabled 50% opacity, not-allowed disabled attr + aria-disabled implicit
loading spinner + aria-busy, click blocked disabled={loading} aria-busy
a11y native <button type>, label or aria-label for icon-only; 44px target

Framework-agnostic HTML/CSS

<button class="btn btn--accent" type="button">Save changes</button>
<button class="btn btn--accent" type="button" aria-busy="true" disabled>
  <span class="btn__spinner" aria-hidden="true"></span> Saving…
</button>
<button class="btn btn--ghost btn--icon" type="button" aria-label="Delete">
  <svg aria-hidden="true" width="16" height="16"><!-- icon --></svg>
</button>
.btn {
  --btn-pad-i: var(--space-4);
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
  min-block-size: 2.5rem;                 /* md; --sm 2rem / --lg 3rem below */
  padding-inline: var(--btn-pad-i);
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  font: 500 var(--text-sm) / var(--leading-sm) var(--font-body);
  letter-spacing: var(--tracking-sm);
  cursor: pointer; user-select: none; white-space: nowrap;
  transition: background var(--dur-fast) var(--ease-out),
              border-color var(--dur-fast) var(--ease-out),
              transform var(--dur-fast) var(--ease-out);
}
.btn:active { transform: scale(.98); }
.btn:disabled, .btn[aria-busy="true"] { opacity: .5; cursor: not-allowed; transform: none; }

/* variants — one dominant accent, sharp neutrals */
.btn--accent    { background: var(--color-accent); color: var(--color-accent-fg); }
.btn--accent:hover:not(:disabled) { background: var(--color-accent-hover); }
.btn--secondary { background: var(--color-surface); color: var(--color-fg); border-color: var(--color-border); }
.btn--secondary:hover:not(:disabled) { background: var(--color-surface-2); }
.btn--ghost     { background: transparent; color: var(--color-fg); }
.btn--ghost:hover:not(:disabled) { background: var(--color-surface-2); }
.btn--danger    { background: var(--color-danger); color: var(--color-danger-fg); }
.btn--danger:hover:not(:disabled) { filter: brightness(1.08); }

/* sizes */
.btn--sm { min-block-size: 2rem;   padding-inline: var(--space-3); font-size: var(--text-xs); }
.btn--lg { min-block-size: 3rem;   padding-inline: var(--space-6); font-size: var(--text-base); }
.btn--icon { padding-inline: 0; inline-size: 2.5rem; }

.btn__spinner {
  inline-size: 1em; block-size: 1em; border-radius: var(--radius-full);
  border: 2px solid currentColor; border-top-color: transparent;
  animation: btn-spin .6s linear infinite;
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .btn__spinner { animation-duration: 1.2s; } }

React 19 + Tailwind v4 (shadcn-style, themed)

// ui/button.tsx
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/cn";

const button = cva(
  "inline-flex items-center justify-center gap-2 rounded-[var(--radius-md)] font-medium " +
    "whitespace-nowrap select-none transition-[background-color,border-color,transform] " +
    "duration-[var(--dur-fast)] ease-[var(--ease-out)] active:scale-[.98] " +
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-ring)] " +
    "disabled:opacity-50 disabled:pointer-events-none aria-busy:opacity-50 aria-busy:pointer-events-none",
  {
    variants: {
      variant: {
        accent: "bg-[var(--color-accent)] text-[var(--color-accent-fg)] hover:bg-[var(--color-accent-hover)]",
        secondary: "bg-[var(--color-surface)] text-[var(--color-fg)] border border-[var(--color-border)] hover:bg-[var(--color-surface-2)]",
        ghost: "text-[var(--color-fg)] hover:bg-[var(--color-surface-2)]",
        danger: "bg-[var(--color-danger)] text-[var(--color-danger-fg)] hover:brightness-110",
      },
      size: { sm: "h-8 px-3 text-xs", md: "h-10 px-4 text-sm", lg: "h-12 px-6 text-base", icon: "h-10 w-10" },
    },
    defaultVariants: { variant: "accent", size: "md" },
  }
);

type ButtonProps = React.ComponentProps<"button"> &
  VariantProps<typeof button> & { loading?: boolean };

export function Button({ className, variant, size, loading, disabled, children, ...props }: ButtonProps) {
  return (
    <button
      className={cn(button({ variant, size }), className)}
      disabled={disabled || loading}
      aria-busy={loading || undefined}   // ref is a normal prop in React 19 — no forwardRef
      {...props}
    >
      {loading && <Spinner />}
      {children}
    </button>
  );
}

function Spinner() {
  return (
    <span
      aria-hidden="true"
      className="size-[1em] animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:[animation-duration:1.2s]"
    />
  );
}

2. Card

A surface container. Flat, hairline-bordered, no card-in-card. Depth comes from the surface ladder (--color-surface on --color-bg) plus a 1px border — not a stack of shadows.

Anatomy: Card > [ Card.Header (title + description) ] · Card.Body · [ Card.Footer (actions) ]. Interactive (clickable) cards add hover lift + a focus ring on the wrapping link/button.

States: static cards have no states. Interactive cards: hover (border brightens, optional 1px --shadow-sm), focus-visible (ring on the anchor), active (scale 0.99). A11y: if the whole card is a link, wrap content in a single <a>; don't nest interactive controls inside a clickable card.

Framework-agnostic HTML/CSS

<article class="card">
  <header class="card__header">
    <h3 class="card__title">Monthly report</h3>
    <p class="card__desc">Usage across all workspaces.</p>
  </header>
  <div class="card__body">…</div>
  <footer class="card__footer">
    <button class="btn btn--secondary btn--sm" type="button">Export</button>
  </footer>
</article>
.card {
  display: flex; flex-direction: column; gap: var(--space-4);
  padding: var(--space-6);
  background: var(--color-surface);
  border: 1px solid var(--color-border);     /* THE depth primitive */
  border-radius: var(--radius-xl);           /* intentional: larger than buttons */
  box-shadow: var(--shadow-sm);
}
.card__header { display: flex; flex-direction: column; gap: var(--space-1); }
.card__title { font: 600 var(--text-lg)/var(--leading-lg) var(--font-display); color: var(--color-fg); }
.card__desc  { font: 400 var(--text-sm)/var(--leading-sm) var(--font-body); color: var(--color-fg-muted); }
.card__footer { display: flex; gap: var(--space-2); margin-block-start: auto; }

/* interactive variant */
a.card, .card--interactive {
  text-decoration: none;
  transition: border-color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);
}
a.card:hover, .card--interactive:hover { border-color: var(--color-fg-subtle); transform: translateY(-2px); }
a.card:active, .card--interactive:active { transform: translateY(0) scale(.99); }

React 19 + Tailwind v4

// ui/card.tsx
import * as React from "react";
import { cn } from "@/lib/cn";

const surface =
  "flex flex-col gap-4 rounded-[var(--radius-xl)] border border-[var(--color-border)] " +
  "bg-[var(--color-surface)] p-6 shadow-[var(--shadow-sm)]";

export function Card({ className, ...p }: React.ComponentProps<"div">) {
  return <div className={cn(surface, className)} {...p} />;
}
export function CardHeader({ className, ...p }: React.ComponentProps<"div">) {
  return <div className={cn("flex flex-col gap-1", className)} {...p} />;
}
export function CardTitle({ className, ...p }: React.ComponentProps<"h3">) {
  return <h3 className={cn("font-[var(--font-display)] text-lg font-semibold text-[var(--color-fg)]", className)} {...p} />;
}
export function CardDescription({ className, ...p }: React.ComponentProps<"p">) {
  return <p className={cn("text-sm text-[var(--color-fg-muted)]", className)} {...p} />;
}
export function CardFooter({ className, ...p }: React.ComponentProps<"div">) {
  return <div className={cn("mt-auto flex gap-2", className)} {...p} />;
}

3. Input + Field (label / help / error)

The accessible-form contract. Field owns the wiring (label for, help/error aria-describedby, aria-invalid); Input is the bare control. Never ship a placeholder-as-label.

Anatomy: Field > Label · [ Input | Textarea | Select ] · [ Help text ] · [ Error (role=alert) ].

States & contract

State Visual Implementation
default surface bg, hairline border border: 1px solid var(--color-border)
hover border → --color-fg-subtle :hover
focus-visible ring + accent border global ring + border-color: var(--color-accent)
disabled --color-bg-subtle, muted text disabled attr
error --color-danger border + message wired via aria-describedby aria-invalid="true"
a11y every input has a <label for>; error role="alert"; required marked in text not color

Framework-agnostic HTML/CSS

<div class="field">
  <label class="field__label" for="email">Email <span class="field__req">(required)</span></label>
  <input class="input" id="email" name="email" type="email" required
         aria-describedby="email-help email-error" aria-invalid="true" />
  <p class="field__help" id="email-help">We'll only use this to send receipts.</p>
  <p class="field__error" id="email-error" role="alert">
    <svg aria-hidden="true" width="14" height="14"><!-- alert icon --></svg>
    Enter a valid email, e.g. name@example.com
  </p>
</div>
.field { display: flex; flex-direction: column; gap: var(--space-2); }
.field__label { font: 500 var(--text-sm)/var(--leading-sm) var(--font-body); color: var(--color-fg); }
.field__req   { color: var(--color-fg-muted); font-weight: 400; }
.field__help  { font-size: var(--text-xs); color: var(--color-fg-muted); }
.field__error {
  display: inline-flex; align-items: center; gap: var(--space-1);
  font-size: var(--text-xs); color: var(--color-danger);   /* icon + text, never color alone */
}

.input {
  min-block-size: 2.5rem;
  padding-inline: var(--space-3);
  background: var(--color-surface);
  color: var(--color-fg);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  font: 400 var(--text-sm)/var(--leading-sm) var(--font-body);
  transition: border-color var(--dur-fast) var(--ease-out);
}
.input::placeholder { color: var(--color-fg-subtle); }
.input:hover:not(:disabled) { border-color: var(--color-fg-subtle); }
.input:focus-visible { border-color: var(--color-accent); outline: 2px solid var(--color-ring); outline-offset: 1px; }
.input:disabled { background: var(--color-bg-subtle); color: var(--color-fg-subtle); cursor: not-allowed; }
.input[aria-invalid="true"] { border-color: var(--color-danger); }
.input[aria-invalid="true"]:focus-visible { outline-color: var(--color-danger); }

React 19 + Tailwind v4 (auto-wired Field)

// ui/field.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/cn";

const FieldCtx = React.createContext<{ id: string; errorId: string; helpId: string; invalid: boolean }>(
  { id: "", errorId: "", helpId: "", invalid: false }
);

export function Field({ children, invalid = false }: { children: React.ReactNode; invalid?: boolean }) {
  const id = React.useId();
  const value = { id, errorId: `${id}-error`, helpId: `${id}-help`, invalid };
  return <FieldCtx value={value}><div className="flex flex-col gap-2">{children}</div></FieldCtx>;
}

export function Label({ className, ...p }: React.ComponentProps<"label">) {
  const { id } = React.use(FieldCtx);
  return <label htmlFor={id} className={cn("text-sm font-medium text-[var(--color-fg)]", className)} {...p} />;
}

export function Input({ className, ...p }: React.ComponentProps<"input">) {
  const { id, errorId, helpId, invalid } = React.use(FieldCtx);
  return (
    <input
      id={id}
      aria-invalid={invalid || undefined}
      aria-describedby={cn(helpId, invalid && errorId)}
      className={cn(
        "h-10 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-fg)]",
        "placeholder:text-[var(--color-fg-subtle)] transition-colors duration-[var(--dur-fast)]",
        "hover:enabled:border-[var(--color-fg-subtle)]",
        "focus-visible:border-[var(--color-accent)] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-ring)]",
        "disabled:bg-[var(--color-bg-subtle)] disabled:text-[var(--color-fg-subtle)] disabled:cursor-not-allowed",
        "aria-[invalid=true]:border-[var(--color-danger)] aria-[invalid=true]:focus-visible:outline-[var(--color-danger)]",
        className
      )}
      {...p}
    />
  );
}

export function HelpText({ className, ...p }: React.ComponentProps<"p">) {
  const { helpId } = React.use(FieldCtx);
  return <p id={helpId} className={cn("text-xs text-[var(--color-fg-muted)]", className)} {...p} />;
}

export function ErrorText({ className, ...p }: React.ComponentProps<"p">) {
  const { errorId, invalid } = React.use(FieldCtx);
  if (!invalid) return null;
  return <p id={errorId} role="alert" className={cn("inline-flex items-center gap-1 text-xs text-[var(--color-danger)]", className)} {...p} />;
}

4. Select

Prefer the native <select> for single-choice — it is the most accessible, mobile-friendly, and zero-JS option. Reach for a custom listbox only when you need rich option content (icons, two-line rows) or multi-select; then build on Base UI Select (data-driven) so the ARIA + keyboard (↑/↓/Home/End/typeahead/Esc) come for free.

States: identical to Input (default/hover/focus/disabled/error) plus an open/expanded state and per-option hover/selected/active-descendant for the custom variant. A11y: native <select> needs only a <label>; the custom version needs role="combobox"/listbox, aria-expanded, aria-activedescendant, and a visible focus ring.

Framework-agnostic HTML/CSS (native — themed)

<div class="field">
  <label class="field__label" for="plan">Plan</label>
  <div class="select">
    <select class="select__control input" id="plan" name="plan">
      <option value="" disabled selected>Choose a plan…</option>
      <option value="pro">Pro — $20/mo</option>
      <option value="team">Team — $40/mo</option>
    </select>
    <svg class="select__chevron" aria-hidden="true" width="16" height="16"><!-- chevron --></svg>
  </div>
</div>
.select { position: relative; display: inline-grid; }
.select__control {
  appearance: none; -webkit-appearance: none;
  padding-inline-end: var(--space-8);          /* room for chevron */
  cursor: pointer;
}
.select__chevron {
  position: absolute; inset-block: 0; inset-inline-end: var(--space-3);
  margin-block: auto; color: var(--color-fg-muted); pointer-events: none;
  transition: transform var(--dur-fast) var(--ease-out);
}
.select__control:focus-visible + .select__chevron { transform: translateY(1px); }

React 19 + Tailwind v4 (Base UI custom listbox, themed)

// ui/select.tsx
"use client";
import { Select as S } from "@base-ui/react/select";
import { cn } from "@/lib/cn";

export function Select({ items, placeholder = "Select…" }: {
  items: { value: string; label: string }[];
  placeholder?: string;
}) {
  return (
    <S.Root items={items}>
      <S.Trigger
        className={cn(
          "inline-flex h-10 min-w-48 items-center justify-between gap-2 rounded-[var(--radius-md)]",
          "border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-fg)]",
          "hover:border-[var(--color-fg-subtle)] data-[popup-open]:border-[var(--color-accent)]",
          "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--color-ring)]",
          "disabled:cursor-not-allowed disabled:bg-[var(--color-bg-subtle)]"
        )}
      >
        <S.Value placeholder={placeholder} className="data-[placeholder]:text-[var(--color-fg-subtle)]" />
        <S.Icon className="text-[var(--color-fg-muted)]">▾</S.Icon>
      </S.Trigger>
      <S.Portal>
        <S.Positioner sideOffset={6}>
          <S.Popup
            className={cn(
              "min-w-[var(--anchor-width)] rounded-[var(--radius-lg)] border border-[var(--color-border)]",
              "bg-[var(--color-surface-2)] p-1 shadow-[var(--shadow-lg)]",
              "origin-[var(--transform-origin)] transition-[transform,opacity] duration-[var(--dur-fast)]",
              "data-[starting-style]:scale-95 data-[starting-style]:opacity-0"
            )}
          >
            {items.map((it) => (
              <S.Item
                key={it.value}
                value={it.value}
                className={cn(
                  "flex cursor-default items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm text-[var(--color-fg)]",
                  "data-[highlighted]:bg-[var(--color-accent)] data-[highlighted]:text-[var(--color-accent-fg)]"
                )}
              >
                <S.ItemIndicator className="text-[var(--color-accent)] data-[highlighted]:text-[var(--color-accent-fg)]">✓</S.ItemIndicator>
                <S.ItemText>{it.label}</S.ItemText>
              </S.Item>
            ))}
          </S.Popup>
        </S.Positioner>
      </S.Portal>
    </S.Root>
  );
}

5. Dialog / Modal

A focus-trapping overlay for a single blocking decision. Use the native <dialog> element for the agnostic version — it gives you the top layer, backdrop, Esc-to-close, and focus trapping for free. The HTML popover/<dialog> combo removed most reasons to hand-roll this.

Anatomy: Backdrop · Dialog [ Header (title + close) · Body · Footer (actions) ].

States & contract

Concern Requirement
open/close animate scale+opacity (≤250ms); data-[starting-style] for enter
focus trap inside; return focus to the trigger on close
keyboard Esc closes; Tab cycles within
semantics role="dialog" aria-modal="true"; aria-labelledby → title; aria-describedby → body
scroll lock background scroll while open
reduced-motion swap transform animation for opacity-only

Framework-agnostic HTML/CSS (native <dialog>)

<button class="btn btn--accent" type="button" data-open-dialog="confirm">Delete project</button>

<dialog class="dialog" id="confirm" aria-labelledby="confirm-title">
  <header class="dialog__header">
    <h2 class="dialog__title" id="confirm-title">Delete this project?</h2>
    <button class="btn btn--ghost btn--icon" type="button" aria-label="Close" data-close-dialog>✕</button>
  </header>
  <p class="dialog__body">This permanently removes all data. This action cannot be undone.</p>
  <footer class="dialog__footer">
    <button class="btn btn--secondary" type="button" data-close-dialog>Cancel</button>
    <button class="btn btn--danger" type="button">Delete</button>
  </footer>
</dialog>
.dialog {
  width: min(32rem, calc(100vw - var(--space-8)));
  padding: var(--space-6);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  background: var(--color-surface);
  color: var(--color-fg);
  box-shadow: var(--shadow-xl);
}
.dialog::backdrop { background: oklch(0 0 0 / 0.5); backdrop-filter: blur(2px); }
/* enter animation — :open is the modern hook; reduced-motion zeroes it via the global rule */
.dialog[open] { animation: dialog-in var(--dur-base) var(--ease-out); }
.dialog[open]::backdrop { animation: backdrop-in var(--dur-base) var(--ease-out); }
@keyframes dialog-in { from { opacity: 0; transform: translateY(8px) scale(.97); } }
@keyframes backdrop-in { from { opacity: 0; } }
.dialog__header { display: flex; align-items: start; justify-content: space-between; gap: var(--space-4); }
.dialog__title { font: 600 var(--text-xl)/var(--leading-xl) var(--font-display); }
.dialog__body { margin-block: var(--space-4); color: var(--color-fg-muted); }
.dialog__footer { display: flex; justify-content: flex-end; gap: var(--space-2); }
// 6 lines of progressive enhancement — native dialog does the trapping + focus return
document.querySelectorAll("[data-open-dialog]").forEach((b) =>
  b.addEventListener("click", () => document.getElementById(b.dataset.openDialog).showModal())
);
document.querySelectorAll("[data-close-dialog]").forEach((b) =>
  b.addEventListener("click", () => b.closest("dialog").close())
);

React 19 + Tailwind v4 (Base UI Dialog, themed)

// ui/dialog.tsx
"use client";
import { Dialog as D } from "@base-ui/react/dialog";
import { cn } from "@/lib/cn";

export function ConfirmDialog({ trigger, title, children, onConfirm }: {
  trigger: React.ReactNode; title: string; children: React.ReactNode; onConfirm: () => void;
}) {
  return (
    <D.Root>
      <D.Trigger render={trigger as React.ReactElement} />
      <D.Portal>
        <D.Backdrop className="fixed inset-0 bg-black/50 backdrop-blur-[2px] transition-opacity duration-[var(--dur-base)] data-[starting-style]:opacity-0" />
        <D.Popup
          className={cn(
            "fixed left-1/2 top-1/2 w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2",
            "rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-xl)]",
            "transition-[transform,opacity] duration-[var(--dur-base)] ease-[var(--ease-out)]",
            "data-[starting-style]:translate-y-[calc(-50%+8px)] data-[starting-style]:scale-[.97] data-[starting-style]:opacity-0",
            "motion-reduce:transition-none"
          )}
        >
          <div className="flex items-start justify-between gap-4">
            <D.Title className="font-[var(--font-display)] text-xl font-semibold text-[var(--color-fg)]">{title}</D.Title>
            <D.Close className="rounded-[var(--radius-md)] p-1 text-[var(--color-fg-muted)] hover:bg-[var(--color-surface-2)] focus-visible:outline-2 focus-visible:outline-[var(--color-ring)]" aria-label="Close">✕</D.Close>
          </div>
          <D.Description className="my-4 text-[var(--color-fg-muted)]">{children}</D.Description>
          <div className="flex justify-end gap-2">
            <D.Close className="h-10 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm hover:bg-[var(--color-surface-2)]">Cancel</D.Close>
            <button onClick={onConfirm} className="h-10 rounded-[var(--radius-md)] bg-[var(--color-danger)] px-4 text-sm text-[var(--color-danger-fg)] hover:brightness-110">Delete</button>
          </div>
        </D.Popup>
      </D.Portal>
    </D.Root>
  );
}

6. Tabs

Switch between sibling views within the same context. Use the WAI-ARIA Tabs pattern: arrow keys move between tabs, Tab jumps from the active tab into the panel.

Anatomy: Tablist [ Tab · Tab · … ] · Tabpanel. An animated underline indicator marks the active tab — one orchestrated movement, not a glow.

States: tab default/hover (fg brightens)/selected (accent underline + --color-fg)/ focus-visible (ring)/disabled. A11y: role="tablist"role="tab" with aria-selected + aria-controls; panel role="tabpanel" + aria-labelledby; inactive tabs tabindex="-1", active tabindex="0" (roving tabindex); ←/→/Home/End move selection.

Framework-agnostic HTML/CSS

<div class="tabs">
  <div class="tabs__list" role="tablist" aria-label="Account settings">
    <button class="tabs__tab" role="tab" id="t-profile" aria-controls="p-profile" aria-selected="true" tabindex="0">Profile</button>
    <button class="tabs__tab" role="tab" id="t-billing" aria-controls="p-billing" aria-selected="false" tabindex="-1">Billing</button>
  </div>
  <div class="tabs__panel" role="tabpanel" id="p-profile" aria-labelledby="t-profile" tabindex="0">…</div>
  <div class="tabs__panel" role="tabpanel" id="p-billing" aria-labelledby="t-billing" tabindex="0" hidden>…</div>
</div>
.tabs__list {
  display: inline-flex; gap: var(--space-1);
  border-block-end: 1px solid var(--color-border);   /* the track */
}
.tabs__tab {
  position: relative; padding: var(--space-2) var(--space-3);
  background: none; border: 0; cursor: pointer;
  font: 500 var(--text-sm)/var(--leading-sm) var(--font-body);
  color: var(--color-fg-muted);
  transition: color var(--dur-fast) var(--ease-out);
}
.tabs__tab:hover { color: var(--color-fg); }
.tabs__tab[aria-selected="true"] { color: var(--color-fg); }
.tabs__tab[aria-selected="true"]::after {   /* the indicator */
  content: ""; position: absolute; inset-inline: var(--space-3); inset-block-end: -1px;
  block-size: 2px; background: var(--color-accent); border-radius: var(--radius-full);
}
.tabs__tab:disabled { color: var(--color-fg-subtle); cursor: not-allowed; }
.tabs__panel { padding-block-start: var(--space-4); }

React 19 + Tailwind v4 (Base UI Tabs with motion underline, themed)

// ui/tabs.tsx
"use client";
import { Tabs as T } from "@base-ui/react/tabs";
import { cn } from "@/lib/cn";

export function Tabs({ tabs }: { tabs: { value: string; label: string; content: React.ReactNode }[] }) {
  return (
    <T.Root defaultValue={tabs[0]?.value}>
      <T.List className="relative flex gap-1 border-b border-[var(--color-border)]">
        {tabs.map((t) => (
          <T.Tab
            key={t.value}
            value={t.value}
            className={cn(
              "px-3 py-2 text-sm font-medium text-[var(--color-fg-muted)] transition-colors duration-[var(--dur-fast)]",
              "hover:text-[var(--color-fg)] data-[selected]:text-[var(--color-fg)]",
              "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-ring)] disabled:text-[var(--color-fg-subtle)]"
            )}
          >
            {t.label}
          </T.Tab>
        ))}
        {/* Base UI's built-in indicator follows the active tab via a CSS transition */}
        <T.Indicator className="absolute bottom-[-1px] h-0.5 rounded-full bg-[var(--color-accent)] transition-all duration-[var(--dur-base)] ease-[var(--ease-out)] left-[var(--active-tab-left)] w-[var(--active-tab-width)] motion-reduce:transition-none" />
      </T.List>
      {tabs.map((t) => (
        <T.Panel key={t.value} value={t.value} className="pt-4 focus-visible:outline-2 focus-visible:outline-[var(--color-ring)]">
          {t.content}
        </T.Panel>
      ))}
    </T.Root>
  );
}

7. Tooltip

A small, non-interactive hint on hover/focus. Never put essential or interactive content in a tooltip (it is invisible to touch and unreliable for AT) — use a Popover for that. Show on both hover and keyboard focus; dismiss on Esc.

States: hidden → showing (after a small open delay ~400ms, instant close); positioned with a flip/shift so it never clips the viewport. A11y: the trigger references the tip via aria-describedby; the tip has role="tooltip"; it must be reachable by keyboard focus, not hover only (WCAG 1.4.13: dismissible, hoverable, persistent).

Framework-agnostic HTML/CSS (CSS-anchor positioned)

<button class="btn btn--ghost btn--icon" type="button" aria-describedby="tip-copy" id="copy">
  <svg aria-hidden="true" width="16" height="16"><!-- copy --></svg>
</button>
<span class="tooltip" role="tooltip" id="tip-copy">Copy to clipboard</span>
#copy { anchor-name: --copy-trigger; }
.tooltip {
  position: absolute; position-anchor: --copy-trigger;
  inset-block-end: anchor(top); inset-inline-start: anchor(center);
  translate: -50% calc(-1 * var(--space-2));
  max-inline-size: 16rem; padding: var(--space-1) var(--space-2);
  background: var(--color-fg); color: var(--color-bg);    /* inverted chip — high contrast */
  border-radius: var(--radius-sm);
  font: 500 var(--text-xs)/var(--leading-xs) var(--font-body);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity var(--dur-fast) var(--ease-out), visibility var(--dur-fast);
}
/* show on hover OR keyboard focus — both required for a11y */
#copy:hover + .tooltip,
#copy:focus-visible + .tooltip { opacity: 1; visibility: visible; }

React 19 + Tailwind v4 (Base UI Tooltip, themed)

// ui/tooltip.tsx
"use client";
import { Tooltip as Tt } from "@base-ui/react/tooltip";
import { cn } from "@/lib/cn";

export function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
  return (
    <Tt.Provider delay={400}>          {/* open delay; close is instant */}
      <Tt.Root>
        <Tt.Trigger render={children as React.ReactElement} />
        <Tt.Portal>
          <Tt.Positioner sideOffset={8}>
            <Tt.Popup
              className={cn(
                "max-w-64 rounded-[var(--radius-sm)] bg-[var(--color-fg)] px-2 py-1 text-xs font-medium text-[var(--color-bg)]",
                "shadow-[var(--shadow-md)] transition-[transform,opacity] duration-[var(--dur-fast)] ease-[var(--ease-out)]",
                "data-[starting-style]:scale-95 data-[starting-style]:opacity-0 motion-reduce:transition-none"
              )}
            >
              {content}
            </Tt.Popup>
          </Tt.Positioner>
        </Tt.Portal>
      </Tt.Root>
    </Tt.Provider>
  );
}

8. Badge

A small status/label chip. Carries meaning via icon + text, never color alone (WCAG 1.4.1). Non-interactive by default; if it represents a removable filter it becomes a Tag with a close button.

Variants: neutral, accent, success, warning, danger. A11y: if the badge conveys status not obvious from text (e.g. a colored dot meaning "online"), give it an accessible name via visually-hidden text or aria-label.

Framework-agnostic HTML/CSS

<span class="badge badge--success"><span class="badge__dot" aria-hidden="true"></span>Active</span>
<span class="badge badge--warning">Pending review</span>
<span class="badge badge--neutral">v4.3</span>
.badge {
  display: inline-flex; align-items: center; gap: var(--space-1);
  padding: var(--space-1) var(--space-2);
  border: 1px solid transparent; border-radius: var(--radius-full);
  font: 500 var(--text-xs)/1 var(--font-body); letter-spacing: var(--tracking-xs);
  white-space: nowrap;
}
.badge__dot { inline-size: .5em; block-size: .5em; border-radius: var(--radius-full); background: currentColor; }
/* tinted, low-chroma fills — each pairs a fg + a subtle bg derived from the semantic color */
.badge--neutral { background: var(--color-surface-2); color: var(--color-fg-muted); border-color: var(--color-border); }
.badge--accent  { background: color-mix(in oklch, var(--color-accent) 14%, transparent); color: var(--color-accent); }
.badge--success { background: color-mix(in oklch, var(--color-success) 14%, transparent); color: var(--color-success); }
.badge--warning { background: color-mix(in oklch, var(--color-warning) 18%, transparent); color: var(--color-warning); }
.badge--danger  { background: color-mix(in oklch, var(--color-danger) 14%, transparent); color: var(--color-danger); }

React 19 + Tailwind v4

// ui/badge.tsx
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/cn";

const badge = cva(
  "inline-flex items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium leading-none whitespace-nowrap",
  {
    variants: {
      variant: {
        neutral: "border-[var(--color-border)] bg-[var(--color-surface-2)] text-[var(--color-fg-muted)]",
        accent: "border-transparent bg-[color-mix(in_oklch,var(--color-accent)_14%,transparent)] text-[var(--color-accent)]",
        success: "border-transparent bg-[color-mix(in_oklch,var(--color-success)_14%,transparent)] text-[var(--color-success)]",
        warning: "border-transparent bg-[color-mix(in_oklch,var(--color-warning)_18%,transparent)] text-[var(--color-warning)]",
        danger: "border-transparent bg-[color-mix(in_oklch,var(--color-danger)_14%,transparent)] text-[var(--color-danger)]",
      },
    },
    defaultVariants: { variant: "neutral" },
  }
);

export function Badge({ className, variant, ...p }: React.ComponentProps<"span"> & VariantProps<typeof badge>) {
  return <span className={cn(badge({ variant }), className)} {...p} />;
}

9. Toast

A transient, non-blocking notification. It must announce itself to AT without stealing focus, so it lives in an aria-live region. Auto-dismiss only for non-critical info; errors stay until dismissed. Stack newest-on-top; cap at 3–4 visible.

States: enter (slide+fade in), resting, exit (fade out), paused-on-hover (resets the timer). A11y: the live region is role="status" aria-live="polite" for info/success, role="alert" aria-live="assertive" for errors; each toast has a close button; never auto-dismiss the only copy of critical information.

Framework-agnostic HTML/CSS

<!-- one persistent region near the end of <body> -->
<div class="toast-region" role="status" aria-live="polite" aria-atomic="false">
  <div class="toast toast--success" role="alert">
    <svg class="toast__icon" aria-hidden="true" width="18" height="18"><!-- check --></svg>
    <div class="toast__copy">
      <p class="toast__title">Saved</p>
      <p class="toast__desc">Your changes are live.</p>
    </div>
    <button class="toast__close" type="button" aria-label="Dismiss">✕</button>
  </div>
</div>
.toast-region {
  position: fixed; inset-block-end: var(--space-4); inset-inline-end: var(--space-4);
  display: flex; flex-direction: column-reverse; gap: var(--space-2);
  inline-size: min(22rem, calc(100vw - var(--space-8))); z-index: 60;
}
.toast {
  display: flex; align-items: start; gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  background: var(--color-surface-2);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  animation: toast-in var(--dur-base) var(--ease-spring) both;
}
.toast--success { border-inline-start: 3px solid var(--color-success); }
.toast--danger  { border-inline-start: 3px solid var(--color-danger); }
.toast__icon { color: var(--color-success); flex-shrink: 0; }
.toast__title { font: 600 var(--text-sm)/var(--leading-sm) var(--font-body); color: var(--color-fg); }
.toast__desc { font-size: var(--text-xs); color: var(--color-fg-muted); }
.toast__close { margin-inline-start: auto; background: none; border: 0; color: var(--color-fg-muted); cursor: pointer; }
@keyframes toast-in { from { opacity: 0; transform: translateX(var(--space-4)); } }
.toast--leaving { animation: toast-out var(--dur-fast) var(--ease-out) forwards; }
@keyframes toast-out { to { opacity: 0; transform: translateY(var(--space-2)); } }

React 19 + Tailwind v4 (Base UI Toast, themed)

// ui/toast.tsx
"use client";
import { Toast } from "@base-ui/react/toast";
import { cn } from "@/lib/cn";

export function ToastProvider({ children }: { children: React.ReactNode }) {
  return <Toast.Provider>{children}<Viewport /></Toast.Provider>;
}

function Viewport() {
  // Base UI renders toasts by mapping the manager's `toasts` array — there is no
  // <Toast.List> render-prop component. The viewport must live inside <Toast.Portal>.
  const { toasts } = Toast.useToastManager();
  return (
    <Toast.Portal>
      <Toast.Viewport className="fixed bottom-4 right-4 z-[60] flex w-[min(22rem,calc(100vw-2rem))] flex-col-reverse gap-2">
        {toasts.map((toast) => (
          <Toast.Root
            key={toast.id}
            toast={toast}
            className={cn(
              "flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-2)] p-3 shadow-[var(--shadow-lg)]",
              "transition-[transform,opacity] duration-[var(--dur-base)] ease-[var(--ease-spring)]",
              "data-[starting-style]:translate-x-4 data-[starting-style]:opacity-0",
              "data-[ending-style]:translate-y-2 data-[ending-style]:opacity-0 motion-reduce:transition-none",
              // custom variant is read from `toast.data` (set when you call toast.add)
              toast.data?.variant === "error" && "border-l-[3px] border-l-[var(--color-danger)]",
              toast.data?.variant === "success" && "border-l-[3px] border-l-[var(--color-success)]"
            )}
          >
            <div className="flex-1">
              <Toast.Title className="text-sm font-semibold text-[var(--color-fg)]" />
              <Toast.Description className="text-xs text-[var(--color-fg-muted)]" />
            </div>
            <Toast.Close className="ml-auto text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] focus-visible:outline-2 focus-visible:outline-[var(--color-ring)]" aria-label="Dismiss">✕</Toast.Close>
          </Toast.Root>
        ))}
      </Toast.Viewport>
    </Toast.Portal>
  );
}

// usage — Base UI sets role="status"/"alert" + aria-live automatically; pass custom variant via `data`
// const toast = Toast.useToastManager();
// toast.add({ title: "Saved", description: "Your changes are live.", data: { variant: "success" } });

10. Empty · Loading · Error states (don't ship without them)

Every data-driven surface needs all three. Match them to the component, and never leave a blank box.

State Pattern A11y
Loading Skeleton blocks (shimmer animates --color-bg-subtle--color-surface-2), not a centered spinner for content; spinner only for actions aria-busy="true" on the region; visually-hidden "Loading"
Empty Icon + one-line headline + one-line guidance + a single primary action ("Create your first project") real copy, not "No data"; action is keyboard-reachable
Error Inline role="alert" with what happened + how to fix + a retry button role="alert" so it is announced; never color-only
.skeleton {
  border-radius: var(--radius-md);
  background: linear-gradient(90deg, var(--color-bg-subtle) 25%, var(--color-surface-2) 37%, var(--color-bg-subtle) 63%);
  background-size: 400% 100%;
  animation: skeleton 1.4s var(--ease-in-out) infinite;
}
@keyframes skeleton { from { background-position: 100% 0; } to { background-position: 0 0; } }
@media (prefers-reduced-motion: reduce) { .skeleton { animation: none; background: var(--color-bg-subtle); } }
// Generic async boundary — React 19 Suspense + error pattern, themed states
function AsyncList({ promise }: { promise: Promise<Item[]> }) {
  const items = use(promise);                       // suspends → fallback below
  if (items.length === 0) return <EmptyState />;    // empty
  return <ul>{items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}
// <Suspense fallback={<div aria-busy className="space-y-2"><div className="skeleton h-6"/><div className="skeleton h-6"/></div>}>
//   <ErrorBoundary fallback={<p role="alert" className="text-[var(--color-danger)]">Couldn't load. <button>Retry</button></p>}>
//     <AsyncList promise={promise} />
//   </ErrorBoundary>
// </Suspense>

11. Dark mode (every component is already covered)

Because each component reads only semantic CONTRACT tokens, dark mode is a values swap — no component CSS changes. Override the color vars under [data-theme="dark"] / .dark, adjusting L and C (never a naive invert): surfaces ride a tinted ladder (not #000), borders become low-alpha white, accent lightness rises slightly for contrast on dark.

[data-theme="dark"], .dark {
  --color-bg:           oklch(0.16 0.006 264);   /* tinted, never pure black */
  --color-bg-subtle:    oklch(0.20 0.006 264);
  --color-surface:      oklch(0.21 0.007 264);
  --color-surface-2:    oklch(0.25 0.008 264);
  --color-fg:           oklch(0.96 0.004 264);
  --color-fg-muted:     oklch(0.74 0.006 264);
  --color-fg-subtle:    oklch(0.56 0.006 264);
  --color-border:        oklch(1 0 0 / 0.10);    /* low-alpha white hairline */
  --color-border-subtle: oklch(1 0 0 / 0.06);
  --color-accent:       oklch(0.70 0.17 264);    /* +L for contrast on dark */
  --color-accent-hover: oklch(0.76 0.16 264);
  --color-ring:         oklch(0.70 0.17 264);
}

The shadow ladder also softens in dark mode (shadows read poorly) — depth leans harder on the surface ladder + hairlines, exactly the Linear/Raycast approach.


Component checklist (audit gate before merge)

  • All states present: default · hover · active · focus-visible · disabled · loading · error
  • Focus ring via --color-ring, 2px, 2px offset, visible on keyboard nav
  • Built on native semantics; ARIA only fills real gaps; correct role + accessible name
  • Full keyboard operation (Tab/arrows/Esc as the pattern requires); 44px targets for primary controls
  • No color-only signaling (icon + text accompany status color)
  • Consumes only semantic CONTRACT tokens — zero hardcoded hex/px/ms
  • Motion ≤ 250ms, transform/opacity only, honors prefers-reduced-motion
  • Works in light + dark via the token swap (no component CSS forks)
  • Empty / loading / error designed, not afterthoughts

Sources

原文ファイル: design-system/components/components.md