BridgeComfortable

Previews use

Applies to every design system preview on the site. The Bridge app chrome stays the same.

Brand

Density

Agents

Three ways in, all generated from the same token files, components and rules. The MCP server gives Claude Code, Claude Desktop or any MCP client a structured view and proposes changes as pull requests. The bridge CLI does the same from a terminal or CI. llms.txt is the whole documentation as plain text, for any agent that can read a URL.

Updated Oct 16, 2026

  • Free and open source, MIT
  • Local: no account, no backend
  • Your repo, your Claude, your GitHub token
  • Any DTCG token repo

llms.txt

One source, two outputs. The pages you read and the file an agent reads come from the same build, so they cannot disagree. /llms.txt holds the agent rules from AGENTS.md, every semantic token with its light and dark value, the scales, failing contrast pairs, and each component's props and usage rules.

lines
1751
agent rules
18
semantic tokens
101
components
46
KB of plain text
128
  • Agents follow short, structured context better than long prose style guides. Values come from the token files, not from a copy.

  • The hard rules are not only in this text: CI blocks raw colors and shadows, so an agent that ignores the file still cannot merge.

Preview llms.txt (1751 lines)
GET /llms.txt Open raw
# Bridge design system

> A design system run as code: W3C DTCG tokens (primitives, brand roles, light/dark semantic aliases, density and
> component tiers) and 46 React components that read CSS variables only. The repo is the source of truth, not Figma.

Generated from the repository on 2026-10-16. Source: https://github.com/peterbono/ai-design-system-demo
Human version of the same content: the docs site pages /foundations/, /tokens/, /components/, /agents/ (Library) and /studio/settings/governance/.

## Library pages (read, latest release)

- [Home](/): What's new and where to start
- [Color](/foundations/color/): Palette ramps and the semantic roles in both themes
- [Typography](/foundations/typography/): Type scale, weights and samples
- [Spacing](/foundations/spacing/): The space scale
- [Radius and borders](/foundations/radius/): Radius roles and border widths
- [Elevation](/foundations/elevation/): Shadows for floating layers
- [Accessibility](/foundations/accessibility/): Contrast grid of every gated color pair, in every brand and theme
- [Explorer](/tokens/): Every token with its alias chain, values, contrast and usage
- [Theme playground](/playground/): A sample product screen in every brand, theme and density
- [Catalog](/components/): Every component with its guidance
- [Install](/install/): Packages, fonts and the DS Bridge Figma plugin
- [Agents](/agents/): MCP server, bridge CLI and llms.txt for AI agents
- [Changelog](/changelog/): Release notes, ready to post

## Studio pages (change: drafts, branches, reviews, versions)

- [Overview](/studio/): To review, health and activity
- [Tokens](/studio/tokens/): Edit tokens into a draft, with live preview and guardrails
- [Components](/studio/components/): Status, tokens, code and Figma links per component
- [Drafts](/studio/drafts/): Token edits waiting in this browser
- [Branches](/studio/branches/): Open branches with previews and snapshot packages
- [Reviews](/studio/reviews/): Open pull requests with token diffs and contrast
- [Versions](/studio/versions/): Releases and merged token changes, with revert
- [Checks](/studio/checks/): Contrast gate and token validation
- [Visual impact](/studio/visual-impact/): Screens and stories each pull request changes
- [Adoption](/studio/adoption/): How much product code uses the design system

## Rules for agents (from AGENTS.md)

### Always

- Work on a branch named agent/<short-topic>. One concern per PR.
- Use semantic or component tokens for color, elevation and focus: var(--color-action-primary-bg), never a primitive (--color-orange-500) and never a literal.
- Use the space, size, radius, type and motion scales. If a value is missing, stop and ask.
- Accessible by default: native elements or Base UI parts, visible focus, labels wired, color never the only signal.
- Write UI copy in the Bridge voice: sentence case, verbs on buttons, errors that say how to fix. No em dashes.
- Add a changeset. Pick the bump with the rules in .changeset/README.md.
- Fill the PR template, including the risk tier, and say the change is agent-authored.

### Ask before

- Adding, renaming or removing any token (Tier 3, needs design and tech owner approval).
- Adding a new component or a new variant or prop (Tier 2, open as draft PR).
- Changing a public prop name, default or type (breaking change, major).
- Adding a dependency, or upgrading @base-ui/react.
- Touching .stylelintrc.json, .github/, AGENTS.md, CLAUDE.md, docs/GOVERNANCE.md or contrast.checks.json.

### Never

- Never write hex, rgb(), named colors, raw box-shadow, px/rem lengths, ms/s durations, cubic-bezier() or raw z-index values in component CSS.
- Never disable or loosen a lint rule or a contrast pair, add stylelint-disable, or use !important.
- Never delete or skip tests or stories to make a check pass.
- Never edit packages/tokens/dist/: it is generated.
- Never treat a Figma file as the source of truth or copy values from it into code. Figma explores; tokens decide.
- Never merge, push to main, or publish. Humans merge.

## Semantic tokens

Use these in components for color, elevation and focus. Format: CSS variable | light | dark.

### color.bg

Page backgrounds. default for the main canvas, subtle for side panels and zones that sit behind content.

- `var(--color-bg-default)` | palette.neutral.50 (#f4f7fa) | palette.neutral.950 (#0d1826) | Page canvas behind panels.
- `var(--color-bg-subtle)` | palette.neutral.100 (#edeff2) | palette.neutral.900 (#1c2735) | Recessed areas: table headers, code blocks, footers.
- `var(--color-bg-muted)` | palette.neutral.200 (#dce3eb) | palette.neutral.800 (#333e4c) | Tracks, skeletons, neutral fills.

### color.surface

Anything that sits on the page: cards, inputs, menus. raised is for surfaces that also carry elevation.

- `var(--color-surface-default)` | palette.neutral.0 (#ffffff) | palette.neutral.900 (#1c2735) | Panels, cards, inputs.
- `var(--color-surface-raised)` | palette.neutral.0 (#ffffff) | palette.neutral.800 (#333e4c) | Floating layers: popovers, menus, dialogs, toasts.
- `var(--color-surface-hover)` | palette.neutral.100 (#edeff2) | palette.neutral.800 (#333e4c) | Hover fill for rows, menu items, ghost controls.
- `var(--color-surface-hover-raised)` | palette.neutral.100 (#edeff2) | palette.neutral.700 (#4b5562) | Hover fill inside a raised surface.
- `var(--color-surface-pressed)` | palette.neutral.200 (#dce3eb) | palette.neutral.700 (#4b5562) | Pressed fill.
- `var(--color-surface-selected)` | palette.accent.50 (#fff4f1) | palette.accent.950 (#320400) | Selected rows and items. Paired with a checked control or a mark, never alone.
- `var(--color-surface-inverse)` | palette.neutral.900 (#1c2735) | palette.neutral.100 (#edeff2) | Tooltips and inverse chips.

### color.text

Text and icons. default for body copy, muted for secondary info, disabled only for disabled controls, inverse on dark or brand fills, on-solid on feedback solid fills.

- `var(--color-text-default)` | palette.neutral.900 (#1c2735) | palette.neutral.50 (#f4f7fa)
- `var(--color-text-muted)` | palette.neutral.600 (#636d79) | palette.neutral.400 (#a2aab5) | Secondary text. 4.5:1 on every background.
- `var(--color-text-disabled)` | palette.neutral.400 (#a2aab5) | palette.neutral.600 (#636d79) | Disabled text. Exempt from contrast minimums; never the only cue.
- `var(--color-text-inverse)` | palette.neutral.0 (#ffffff) | palette.neutral.950 (#0d1826) | Text on surface.inverse.
- `var(--color-text-on-solid)` | palette.neutral.0 (#ffffff) | palette.neutral.0 (#ffffff) | Text and icons on feedback and danger solids.
- `var(--color-text-link)` | palette.link.600 (#22749e) | palette.link.300 (#a7d5f1)
- `var(--color-text-link-hover)` | palette.link.700 (#0a5b80) | palette.link.200 (#cae7f9)
- `var(--color-text-signal)` | palette.accent.700 (#962600) | palette.accent.300 (#fdbcaa) | Signal-colored text (match highlights, current step).

### color.border

Dividers and outlines. default for quiet separators, strong for control edges that must be found.

- `var(--color-border-default)` | palette.neutral.200 (#dce3eb) | palette.neutral.800 (#333e4c) | Hairlines: card edges, dividers. Decorative, no contrast minimum.
- `var(--color-border-subtle)` | palette.neutral.100 (#edeff2) | palette.neutral.900 (#1c2735) | Row separators inside tables and lists.
- `var(--color-border-strong)` | palette.neutral.500 (#818a96) | palette.neutral.500 (#818a96) | Edges that identify a control (inputs, checkboxes, switches). 3:1 against surfaces (WCAG 1.4.11).
- `var(--color-border-hover)` | palette.neutral.600 (#636d79) | palette.neutral.400 (#a2aab5) | Control edge on hover.

### color.action

Interactive controls only. Never use action colors for decoration, or users will try to click it.

- `var(--color-action-primary-bg)` | palette.primary.900 (#1c2735) | palette.primary.50 (#f4f7fa) | Primary action fill (steel ink in the bridge brand).
- `var(--color-action-primary-bg-hover)` | palette.primary.800 (#333e4c) | palette.primary.200 (#dce3eb)
- `var(--color-action-primary-bg-active)` | palette.primary.950 (#0d1826) | palette.primary.300 (#c7ced8)
- `var(--color-action-primary-text)` | palette.neutral.0 (#ffffff) | palette.primary.950 (#0d1826)
- `var(--color-action-secondary-bg)` | palette.neutral.0 (#ffffff) | palette.neutral.900 (#1c2735)
- `var(--color-action-secondary-bg-hover)` | palette.neutral.100 (#edeff2) | palette.neutral.800 (#333e4c)
- `var(--color-action-secondary-bg-active)` | palette.neutral.200 (#dce3eb) | palette.neutral.700 (#4b5562)
- `var(--color-action-secondary-text)` | palette.neutral.900 (#1c2735) | palette.neutral.50 (#f4f7fa)
- `var(--color-action-secondary-border)` | palette.neutral.300 (#c7ced8) | palette.neutral.600 (#636d79)
- `var(--color-action-ghost-bg-hover)` | palette.neutral.100 (#edeff2) | palette.neutral.800 (#333e4c)
- `var(--color-action-ghost-bg-active)` | palette.neutral.200 (#dce3eb) | palette.neutral.700 (#4b5562)
- `var(--color-action-ghost-text)` | palette.neutral.800 (#333e4c) | palette.neutral.100 (#edeff2)
- `var(--color-action-danger-bg)` | palette.danger.600 (#c3184c) | palette.danger.600 (#c3184c)
- `var(--color-action-danger-bg-hover)` | palette.danger.700 (#9d0639) | palette.danger.700 (#9d0639)
- `var(--color-action-danger-bg-active)` | palette.danger.800 (#760028) | palette.danger.800 (#760028)
- `var(--color-action-danger-text)` | palette.neutral.0 (#ffffff) | palette.neutral.0 (#ffffff)
- `var(--color-action-disabled-bg)` | palette.neutral.100 (#edeff2) | palette.neutral.800 (#333e4c)
- `var(--color-action-disabled-text)` | palette.neutral.400 (#a2aab5) | palette.neutral.600 (#636d79)

### color.control

- `var(--color-control-bg)` | palette.neutral.0 (#ffffff) | palette.neutral.900 (#1c2735) | Unchecked checkbox, radio, input fill.
- `var(--color-control-checked-bg)` | palette.primary.900 (#1c2735) | palette.primary.50 (#f4f7fa) | Checked checkbox, radio, switch track. Selection is ink.
- `var(--color-control-checked-bg-hover)` | palette.primary.800 (#333e4c) | palette.primary.200 (#dce3eb)
- `var(--color-control-checked-fg)` | palette.neutral.0 (#ffffff) | palette.primary.950 (#0d1826) | Check mark, radio dot, switch thumb when checked.
- `var(--color-control-track)` | palette.neutral.500 (#818a96) | palette.neutral.500 (#818a96) | Unchecked switch track and slider rail edge. 3:1 against surfaces.
- `var(--color-control-thumb)` | palette.neutral.0 (#ffffff) | palette.neutral.50 (#f4f7fa)

### color.signal

- `var(--color-signal-default)` | palette.accent.500 (#e64611) | palette.accent.400 (#fb8160) | The signal: active indicators, progress, slider range.
- `var(--color-signal-subtle)` | palette.accent.100 (#ffeae4) | palette.accent.900 (#4a0e00) | Soft signal fill: highlighted search matches.

### color.overlay

- `var(--color-overlay-scrim)` | color.alpha.steel-48 (rgb(13 24 37 / 0.48)) | color.alpha.black-64 (rgb(0 0 0 / 0.64)) | Backdrop behind dialogs and drawers.

### color.feedback

Status and system messages. bg + text + border for inline messages, solid for compact labels like badges. Always pair with words.

- `var(--color-feedback-success-bg)` | palette.success.50 (#eefaf6) | palette.success.950 (#001d16)
- `var(--color-feedback-success-border)` | palette.success.200 (#c4ece1) | palette.success.800 (#06483c)
- `var(--color-feedback-success-text)` | palette.success.700 (#036252) | palette.success.300 (#9eddcc)
- `var(--color-feedback-success-icon)` | palette.success.600 (#007e6a) | palette.success.400 (#62bda7)
- `var(--color-feedback-success-solid)` | palette.success.600 (#007e6a) | palette.success.600 (#007e6a)
- `var(--color-feedback-warning-bg)` | palette.warning.50 (#fef6ea) | palette.warning.950 (#221400)
- `var(--color-feedback-warning-border)` | palette.warning.200 (#f8deb6) | palette.warning.800 (#533700)
- `var(--color-feedback-warning-text)` | palette.warning.700 (#6f4c03) | palette.warning.300 (#fad091)
- `var(--color-feedback-warning-icon)` | palette.warning.600 (#8e6201) | palette.warning.400 (#e9b154)
- `var(--color-feedback-warning-solid)` | palette.warning.600 (#8e6201) | palette.warning.600 (#8e6201)
- `var(--color-feedback-danger-bg)` | palette.danger.50 (#fff4f4) | palette.danger.950 (#34000c)
- `var(--color-feedback-danger-border)` | palette.danger.200 (#ffd6da) | palette.danger.800 (#760028)
- `var(--color-feedback-danger-text)` | palette.danger.700 (#9d0639) | palette.danger.300 (#ffb8bf)
- `var(--color-feedback-danger-icon)` | palette.danger.600 (#c3184c) | palette.danger.400 (#fd798e)
- `var(--color-feedback-danger-solid)` | palette.danger.600 (#c3184c) | palette.danger.600 (#c3184c)
- `var(--color-feedback-info-bg)` | palette.info.50 (#f0f8fe) | palette.info.950 (#001a29)
- `var(--color-feedback-info-border)` | palette.info.200 (#cae7f9) | palette.info.800 (#03425f)
- `var(--color-feedback-info-text)` | palette.info.700 (#0a5b80) | palette.info.300 (#a7d5f1)
- `var(--color-feedback-info-icon)` | palette.info.600 (#22749e) | palette.info.400 (#73b2d8)
- `var(--color-feedback-info-solid)` | palette.info.600 (#22749e) | palette.info.600 (#22749e)

### color.selection

- `var(--color-selection-bg)` | palette.accent.100 (#ffeae4) | palette.accent.800 (#6e1c02) | Text selection highlight (::selection).

### focus

Keyboard focus ring. Shared by every focusable component so focus looks the same everywhere.

- `var(--focus-ring-color)` | palette.accent.500 (#e64611) | palette.accent.400 (#fb8160) | Keyboard focus ring. 3:1 against every surface.
- `var(--focus-ring-width)` | border.width.2 (2px) | border.width.2 (2px)
- `var(--focus-ring-offset)` | border.width.2 (2px) | border.width.2 (2px)

### elevation

Shadows that lift surfaces. raised for cards, overlay for menus and dialogs. Stronger in dark mode so edges stay visible.

- `var(--elevation-raised)` | shadow.1 (0px 1px 2px 0px rgb(13 24 37 / 0.06), 0px 2px 6px -1px rgb(13 24 37 / 0.08)) | shadow.dark-1 (0px 1px 2px 0px rgb(0 0 0 / 0.32), 0px 2px 6px -1px rgb(0 0 0 / 0.24)) | Interactive cards on hover, sticky headers.
- `var(--elevation-overlay)` | shadow.2 (0px 2px 4px -1px rgb(13 24 37 / 0.08), 0px 12px 24px -6px rgb(13 24 37 / 0.14)) | shadow.dark-2 (0px 2px 4px -1px rgb(0 0 0 / 0.4), 0px 12px 24px -6px rgb(0 0 0 / 0.44)) | Popovers, menus, listboxes, tooltips.
- `var(--elevation-modal)` | shadow.3 (0px 4px 8px -2px rgb(13 24 37 / 0.1), 0px 24px 48px -12px rgb(13 24 37 / 0.22)) | shadow.dark-3 (0px 4px 8px -2px rgb(0 0 0 / 0.48), 0px 24px 48px -12px rgb(0 0 0 / 0.56)) | Dialogs, drawers, toasts.

### motion

- `var(--motion-duration-instant)` | duration.75 (75ms) | duration.75 (75ms) | Hover and press color changes.
- `var(--motion-duration-fast)` | duration.150 (150ms) | duration.150 (150ms) | Tooltips, menus, popovers, checkbox ticks.
- `var(--motion-duration-moderate)` | duration.200 (200ms) | duration.200 (200ms) | Dialogs, accordions, tab indicator.
- `var(--motion-duration-slow)` | duration.300 (300ms) | duration.300 (300ms) | Drawers, toasts entering.
- `var(--motion-duration-spin)` | duration.700 (700ms) | duration.700 (700ms) | One spinner rotation.
- `var(--motion-duration-shimmer)` | duration.1400 (1400ms) | duration.1400 (1400ms) | One skeleton shimmer pass.
- `var(--motion-easing-standard)` | easing.standard (cubic-bezier(0.2, 0, 0, 1)) | easing.standard (cubic-bezier(0.2, 0, 0, 1))
- `var(--motion-easing-enter)` | easing.expo-out (cubic-bezier(0.16, 1, 0.3, 1)) | easing.expo-out (cubic-bezier(0.16, 1, 0.3, 1))
- `var(--motion-easing-exit)` | easing.accel (cubic-bezier(0.4, 0, 1, 1)) | easing.accel (cubic-bezier(0.4, 0, 1, 1))
- `var(--motion-easing-linear)` | easing.linear (cubic-bezier(0, 0, 1, 1)) | easing.linear (cubic-bezier(0, 0, 1, 1))

### layer

- `var(--layer-sticky)` | z.sticky (100) | z.sticky (100)
- `var(--layer-dropdown)` | z.dropdown (1000) | z.dropdown (1000)
- `var(--layer-overlay)` | z.overlay (1100) | z.overlay (1100)
- `var(--layer-modal)` | z.modal (1200) | z.modal (1200)
- `var(--layer-popover)` | z.popover (1300) | z.popover (1300)
- `var(--layer-toast)` | z.toast (1400) | z.toast (1400)
- `var(--layer-tooltip)` | z.tooltip (1500) | z.tooltip (1500)

### icon

- `var(--icon-size-xs)` | size.3 (12px) | size.3 (12px)
- `var(--icon-size-sm)` | size.4 (16px) | size.4 (16px)
- `var(--icon-size-md)` | size.5 (20px) | size.5 (20px)
- `var(--icon-size-lg)` | size.6 (24px) | size.6 (24px)
- `var(--icon-stroke)` | 1.75 | 1.75 | Lucide stroke width at every size, matched to Plex text weight.

### border-width

- `var(--border-width-default)` | border.width.1 (1px) | border.width.1 (1px)
- `var(--border-width-strong)` | border.width.2 (2px) | border.width.2 (2px)
- `var(--border-width-indicator)` | border.width.2 (2px) | border.width.2 (2px)

## Scales (primitives, same in both themes)

- Spacing: `--space-0` 0px, `--space-1` 4px, `--space-2` 8px, `--space-3` 12px, `--space-4` 16px, `--space-5` 20px, `--space-6` 24px, `--space-7` 28px, `--space-8` 32px, `--space-10` 40px, `--space-12` 48px, `--space-16` 64px, `--space-20` 80px, `--space-24` 96px, `--space-0-5` 2px, `--space-1-5` 6px
- Radius: `--radius-none` 0px, `--radius-xs` 2px, `--radius-sm` 4px, `--radius-md` 6px, `--radius-lg` 10px, `--radius-xl` 14px, `--radius-2xl` 16px, `--radius-3xl` 20px, `--radius-full` 9999px
- Border width: `--border-width-1` 1px, `--border-width-2` 2px, `--border-width-3` 3px
- Font size: `--font-size-100` 12px, `--font-size-200` 14px, `--font-size-300` 17px, `--font-size-400` 20px, `--font-size-500` 24px, `--font-size-600` 29px, `--font-size-700` 35px, `--font-size-800` 42px
- Font weight: `--font-weight-regular` 400, `--font-weight-medium` 500, `--font-weight-semibold` 600, `--font-weight-bold` 700
- Line height: `--font-line-height-none` 1, `--font-line-height-tight` 1.2, `--font-line-height-snug` 1.35, `--font-line-height-normal` 1.5, `--font-line-height-relaxed` 1.65

If a value is not in a scale, do not invent one: propose a token in the PR.

## Contrast

Pairs are listed in packages/tokens/src/contrast.checks.json and checked against WCAG AA (4.5:1 text, 3:1 UI).
All listed pairs pass AA in both themes.

## Components

### Icon

Import: `import { Icon } from '@bridge-ds/react'`
Props:
- icon: LucideIcon. A Lucide icon component, for example `import { GitBranch } from 'lucide-react'`.
- size?: 'xs' | 'sm' | 'md' | 'lg' = 'sm'. Token size: xs 12, sm 16, md 20, lg 24. Default `sm`, the size used inside controls.
- label?: string. Accessible name. Omit for decorative icons next to text (they are hidden from assistive tech).
Tokens used: --icon-size-lg, --icon-size-md, --icon-size-sm, --icon-size-xs, --icon-stroke

### VisuallyHidden

Import: `import { VisuallyHidden } from '@bridge-ds/react'`
Accepts all native <span> attributes.
Tokens used: --border-width-1

### Button

Triggers an action on the current screen: save, publish, approve, open a dialog. If it takes the user somewhere else, it is a Link, not a Button.

Import: `import { Button } from '@bridge-ds/react'`
Accepts all native <button> attributes.
Props:
- variant?: 'primary' | 'secondary' | 'ghost' | 'danger' = 'primary'. Visual emphasis. Use one primary action per view; danger only for destructive actions.
- size?: 'sm' | 'md' | 'lg' = 'md'. Height follows the density tokens: sm 32/28, md 40/32, lg 48/40 (comfortable/compact).
- loading?: boolean = false. Shows a spinner, keeps the button focusable, ignores clicks and sets aria-busy.
- iconStart?: LucideIcon. Lucide icon before the label. Replaced by the spinner while loading.
- iconEnd?: LucideIcon. Lucide icon after the label, for example a chevron on a menu trigger.
- fullWidth?: boolean = false. Stretch to the width of the container.
- render?: ReactElement. Render another element with button styling, for example a router link: `render={<a href="/tokens" />}`. The element keeps its own role (a link stays a link), so only use it for navigation.
When to use:
- The user commits an action on this screen: Publish, Approve, Create token, Open pull request.
- Primary: the one action you most want the user to take in this view. One per form, dialog or page section.
- Secondary: the alternatives next to the primary, like Save as draft or Request changes.
- Ghost: low-emphasis actions repeated in lists, tables or toolbars (Edit, View diff), where filled buttons would fight for attention.
- Danger: destructive actions (Delete, Revoke access). Usually inside a confirmation dialog.
- Size sm in dense rows and toolbars, md everywhere else, lg only for a single call to action on an empty or onboarding screen. Heights follow the density setting.
When not to use:
- Going to another page or URL: use Link. Links and buttons are announced differently and users expect Cmd+click to open links in a new tab.
- Turning a setting on or off: use Switch or Checkbox.
- Picking one of several views: use Tabs or an attached ButtonGroup.
- More than three actions in one row: keep the main ones and move the rest to a Menu behind an IconButton.
Do:
- Start the label with a verb that names the outcome. "Publish tokens" and "Approve change" tell the user what happens before they click.
- One primary per view, the others step down. Hierarchy tells users where to look first. Secondary and ghost carry the alternatives.
- Show progress with loading, keep the label. The button stays in place and focusable, so keyboard users do not lose their position. Change the label to the present participle.
Don't:
- Two primary buttons side by side. When everything is primary, nothing is. Users hesitate and pick at random.
- Vague labels. "OK" and "Click here" say nothing out of context, and screen reader users often list buttons out of context.
- Danger for actions that are not destructive. Red trains people to ignore red. Keep danger for actions that remove or revoke something.
Accessibility:
- Native button through Base UI: Enter and Space activate it, it is in the tab order, no extra ARIA needed.
- The focus ring (focus.ring.color, 2px, offset 2px) shows on keyboard focus only, never on mouse click.
- Loading sets aria-busy and aria-disabled, ignores clicks, and keeps the button focusable so keyboard users keep their place.
- Disabled removes the button from the tab order and explains nothing. Prefer an enabled button plus a message saying what is missing.
- With render={<a href />} the element stays a link (role link), which is right for navigation styled as a button.
- Heights are 32/40/48px comfortable and 28/32/40px compact: all above the WCAG 2.2 minimum target of 24px.
Tokens used: --border-width-default, --button-danger-bg, --button-danger-bg-active, --button-danger-bg-hover, --button-danger-text, --button-font-weight, --button-ghost-bg-active, --button-ghost-bg-hover, --button-ghost-text, --button-primary-bg, --button-primary-bg-active, --button-primary-bg-hover, --button-primary-text, --button-radius, --button-secondary-bg, --button-secondary-bg-active, --button-secondary-bg-hover, --button-secondary-border, --button-secondary-text, --color-action-disabled-bg, --color-action-disabled-text, --control-gap, --control-height-lg, --control-height-md, --control-height-sm, --control-padding-x-lg, --control-padding-x-md, --control-padding-x-sm, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-tight, --font-size-200, --font-size-300, --motion-duration-instant, --motion-easing-standard, --space-1, --space-1-5

### ButtonGroup

Lays out related buttons as one unit, either spaced (an action bar or dialog footer) or attached (a segmented control), and gives the group one accessible name.

Import: `import { ButtonGroup } from '@bridge-ds/react'`
Accepts all native <div> attributes.
Props:
- appearance?: 'spaced' | 'attached' = 'spaced'. Accessible name for the group, for example "Text alignment". Required when the buttons are related choices. */ 'aria-label'?: string; /** `attached` joins the buttons into one segmented control; `spaced` keeps a gap (action bars). Default `spaced`.
- orientation?: 'horizontal' | 'vertical' = 'horizontal'
- align?: 'start' | 'end' | 'between' = 'start'. Align the group's buttons. Default `start`. Use `end` for dialog and form footers.
When to use:
- Footers of dialogs, forms and cards: primary at the end, alternatives before it.
- Attached: two to five closely related choices of equal weight, like Unified or Split diff.
When not to use:
- Switching between views of content: use Tabs, which manage the panel and arrow-key navigation.
- A single choice that persists as a setting: use RadioGroup.
Do:
- Put the primary action last. Users read the alternatives on the way to the main action, and the primary lands where the eye ends.
Don't:
- Mix variants in an attached group. An attached group reads as one control. Mixed fills break it into unrelated pieces.
Accessibility:
- Renders role="group". Pass aria-label when the buttons are related choices so the group is announced.
- Each button stays in the tab order; the focused button lifts above its neighbors so the ring is never clipped.
- Vertical groups stack the same buttons; the tab order follows the visual order.
Tokens used: --border-width-default, --space-2

### IconButton

A square button with only an icon, for frequent, recognizable actions in toolbars, table rows and headers. Its label is still required: it becomes the accessible name.

Import: `import { IconButton } from '@bridge-ds/react'`
Accepts all native <button> attributes except children, aria-label.
Props:
- icon: LucideIcon. Lucide icon component.
- label: string. Accessible name, required: there is no visible text. Say the action, not the icon ("Delete token", not "Trash").
- variant?: ButtonVariant = 'ghost'. Default `ghost`: icon buttons usually live in toolbars and rows.
- size?: ButtonSize = 'md'
- loading?: boolean = false
When to use:
- Repeated row actions in tables and lists: edit, copy, delete, more.
- Toolbars where space is tight and the icons are widely understood (close, copy, search, settings).
- Opening a Menu of secondary actions (the more icon).
When not to use:
- The primary action of a view: give it a text Button.
- Any icon whose meaning is not obvious without its label: use a Button with iconStart instead.
- Navigation to another page: use a Link.
Do:
- Name the action, not the picture. label="Delete token" is what a screen reader says. "Trash" describes the drawing, not what happens.
- Pair with a Tooltip showing the same label. Sighted mouse users get the name on hover; keyboard users get it on focus.
Don't:
- A row of filled icon buttons. Primary icon buttons in every row shout. Keep row actions ghost.
Accessibility:
- label is required and becomes aria-label. The icon itself is aria-hidden.
- Same keyboard behavior and focus ring as Button. Loading keeps focus and sets aria-busy.
- The square target matches the control height (32/40/48 comfortable), never smaller than 24px.
Tokens used: --font-line-height-none

### Link

Takes the user to another page, section or site. Underlined in running text so it is never identified by color alone.

Import: `import { Link } from '@bridge-ds/react'`
Accepts all native <a> attributes.
Props:
- variant?: 'inline' | 'standalone' | 'subtle' = 'inline'. `inline` sits in running text (always underlined); `standalone` stands alone (underline on hover); `subtle` inherits the text color. Default `inline`.
- external?: boolean = false. Opens in a new tab: adds target, rel, an icon and "(opens in a new tab)" for screen readers.
- render?: ReactElement. Render a router link instead of `<a>`: `render={<RouterLink to="/tokens" />}`.
When to use:
- Navigation: another page, a section of this page, an external resource.
- Inline in sentences (inline variant), or on its own under a card or a table (standalone).
- external for other sites: it opens a new tab and says so.
When not to use:
- Actions on this screen (save, delete, open a dialog): use Button.
- Navigation that should look like a button (a single call to action): use Button with render={<a href />}.
Do:
- Link text says where it goes. Screen reader users jump between links out of context: "Read the token guidelines" works alone, "here" does not.
Don't:
- "Click here" or a bare URL. Both say nothing about the destination.
Accessibility:
- Native <a href>: Enter follows it, it is announced as a link, Cmd or Ctrl+click opens a new tab.
- Inline links are underlined (WCAG 1.4.1): color is never the only cue inside text.
- external adds target=_blank, rel=noopener noreferrer, an icon and "(opens in a new tab)" for screen readers.
- Focus ring hugs the text with a 1px offset so it does not overlap neighbouring lines.
Tokens used: --border-width-1, --border-width-2, --color-border-strong, --color-text-link, --color-text-link-hover, --focus-ring-color, --focus-ring-width, --font-weight-medium, --motion-duration-instant, --motion-easing-standard, --radius-inset, --space-0-5

### Checkbox

A single on or off choice with its label next to the box, submitted with a form. Supports a mixed state for a parent that controls a partly checked group.

Import: `import { Checkbox } from '@bridge-ds/react'`
Props:
- label: ReactNode. Visible label next to the box. Clicking it toggles the checkbox.
- description?: ReactNode. Secondary text under the label, linked with aria-describedby.
- checked?: boolean
- defaultChecked?: boolean
- onCheckedChange?: (checked: boolean) => void
- indeterminate?: boolean. Mixed state, for a parent that controls a partly checked group. Announced as "mixed".
- parent?: boolean. Inside a CheckboxGroup with allValues: this checkbox selects or clears the whole group.
- value?: string. Value submitted with the form, and the item id inside a CheckboxGroup.
- name?: string
- disabled?: boolean
- readOnly?: boolean
- required?: boolean
- invalid?: boolean = false. Marks the checkbox invalid (red edge). Pair it with an error message on the group or form.
- id?: string
- className?: string
When to use:
- An independent yes or no that takes effect when the form is saved: agree, include, require.
- Several independent options under one question: put them in a CheckboxGroup.
- Selecting rows in a Table (with a parent checkbox in the header for select all).
When not to use:
- A setting that applies immediately: use Switch.
- One choice among exclusive options: use RadioGroup.
- A command (Delete, Publish): use Button.
Do:
- Phrase the label as the positive case. Checked means the label is true. "Notify reviewers" reads cleanly; a negative label makes users double-check.
- Use the description for consequences. Keep the label short and explain the effect underneath.
Don't:
- Negative labels. "Do not send notifications" checked means off. Users have to think twice.
- A checkbox that acts immediately. If ticking it changes something right away, it is a Switch.
Accessibility:
- Base UI Checkbox: role checkbox, Space toggles, aria-checked is true, false or mixed (indeterminate).
- The whole label row is the click target; the label text is the accessible name (aria-labelledby) and the description is linked with aria-describedby.
- The box edge uses color.border.strong (3:1 against surfaces); checked uses the ink fill with a white check (4.5:1 or more).
- Indeterminate shows a dash, so mixed is not conveyed by color.
- invalid adds a danger edge; always pair it with an error message in the group or form.
Tokens used: --border-width-default, --checkbox-bg, --checkbox-bg-checked, --checkbox-border, --checkbox-fg-checked, --checkbox-radius, --color-action-disabled-bg, --color-action-disabled-text, --color-border-default, --color-border-hover, --color-control-checked-bg-hover, --color-text-default, --color-text-disabled, --color-text-muted, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-size-100, --font-size-200, --font-tracking-wide, --font-weight-regular, --input-border-invalid, --motion-duration-fast, --motion-duration-instant, --motion-easing-enter, --motion-easing-standard, --size-4, --size-6, --space-0-5, --space-2

### CheckboxGroup

Several independent choices under one question, in a fieldset with a legend. An optional parent checkbox selects or clears the whole group and shows a mixed state.

Import: `import { CheckboxGroup } from '@bridge-ds/react'`
Props:
- label: ReactNode. Group label, rendered as the fieldset legend.
- children: ReactNode. Checkbox items, each with a `value`.
- description?: ReactNode. Help under the legend.
- error?: ReactNode. Error for the group, for example "Pick at least one reviewer." Linked with aria-describedby.
- value?: string[]. Values of the checked items (controlled).
- defaultValue?: string[]
- onValueChange?: (value: string[]) => void
- allValues?: string[]. Every item value. Required for a parent "Select all" checkbox: add `<Checkbox parent label="All" />` and the group drives its checked and mixed state.
- orientation?: 'vertical' | 'horizontal' = 'vertical'. Default `vertical`.
- disabled?: boolean
- required?: boolean = false
- hideLabel?: boolean = false
- className?: string
When to use:
- Users may pick zero, one or several options from a short visible list (up to about seven).
- Use allValues plus <Checkbox parent /> for a Select all at the top of a long list.
When not to use:
- Only one option can be picked: use RadioGroup.
- More than about seven options or a searchable list: use Combobox with multiple selection.
Do:
- Ask the question in the legend. The legend is read before each option, so it gives every label its context.
Don't:
- A group without a legend. Screen reader users hear "Light, checkbox" with no idea what it is for.
Accessibility:
- Native fieldset and legend: the legend names the group; disabled on the fieldset disables every item.
- description and error are linked to the fieldset with aria-describedby; the error carries an icon and text.
- The parent checkbox is checked, unchecked or mixed (aria-checked) based on the children, driven by Base UI.
- Each item keeps its own label and description, so items can be reached and understood one by one.
Tokens used: --border-width-1, --color-feedback-danger-icon, --color-feedback-danger-text, --color-text-default, --color-text-disabled, --color-text-muted, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-100, --font-size-200, --font-tracking-wide, --font-weight-medium, --input-border-invalid, --space-0-5, --space-1, --space-1-5, --space-2, --space-6

### Combobox

A text input that filters a list of options as the user types, then selects one or several of them. Built for long lists and async search (people, repositories, tokens).

Import: `import { Combobox } from '@bridge-ds/react'`
Accepts all native <input> attributes except value, defaultValue, onChange, size, prefix, children.
Props:
- options: ComboboxOption[]. The options to filter. For async search, pass the latest results and set `filter={null}`.
- multiple?: boolean = false. Pick several values, shown as removable chips.
- value?: ComboboxValue. Selected value (controlled): a string, an array with `multiple`, or null.
- defaultValue?: ComboboxValue. Initially selected value (uncontrolled).
- onValueChange?: (value: ComboboxValue) => void. Called with the new value (string, string[] with `multiple`, or null).
- inputValue?: string. Text in the input (controlled). Use it to drive an async search.
- defaultInputValue?: string
- onInputValueChange?: (inputValue: string) => void. Called on every keystroke with the query.
- filter?: ((option: ComboboxOption, query: string) => boolean) | null. Custom matcher, or `null` to show `options` as given (already filtered by a server). Default: case and accent insensitive "contains".
- loading?: boolean = false. Results are loading: shows a spinner row, sets aria-busy and announces `loadingMessage`.
- loadingMessage?: string = 'Loading results'. Announced and shown while loading. Default "Loading results".
- emptyMessage?: ReactNode = 'No matches. Try another name.'. Shown when no option matches. Say what to try next.
- label?: string. Visible label. When set, the Combobox wraps itself in a Field. Leave unset inside a Field.
- hint?: ReactNode. Help under the label (Field description). Only with `label`.
- error?: ReactNode. Error message. Only with `label`.
- hideLabel?: boolean
- size?: 'sm' | 'md' | 'lg' = 'md'. Height follows the density tokens. Default `md`.
- prefix?: ReactNode. Content before the query, usually a search Icon.
- open?: boolean. Popup open state (controlled).
- defaultOpen?: boolean
- onOpenChange?: (open: boolean) => void
When to use:
- Choosing from a long or growing list where typing is faster than scrolling: reviewers, repositories, components, tokens.
- Results come from a server: control inputValue, fetch, pass the results as options with filter={null} and loading while waiting.
- Several values from a long list (multiple): selected values become removable chips inside the field.
When not to use:
- Short known lists (under about ten): use Select, which needs no typing.
- Free text with suggestions (a search box): the value must be one of the options here, so use Input or an autocomplete pattern.
- Two or three options: use RadioGroup.
Do:
- Add a second line when names are ambiguous. A role or an email under the name helps pick the right Ada, and is matched by screen readers too.
- Write an empty message that says what to try. "No one matches. Check the spelling or invite them." turns a dead end into a next step.
Don't:
- Spinner with no words. While loading, say what is happening ("Searching people"). The message is also announced to screen readers.
- Combobox for a handful of fixed options. Typing to find one of three themes is slower than seeing them. Use Select or RadioGroup.
Accessibility:
- Base UI Combobox: the input has role combobox with aria-expanded, aria-controls and aria-activedescendant; the popup is a listbox.
- Keyboard: typing filters; Down Arrow opens and moves; Enter selects; Escape closes, then clears; with multiple, Left Arrow from the start of the input moves through the chips and Backspace removes one.
- loading renders the message in a live status region and sets aria-busy on the popup; the empty message is announced the same way.
- The clear and toggle buttons inside the field have accessible names ("Clear selection", "Show options"); each chip remove button says "Remove <name>".
- The Field label names the input; hint and error are linked through aria-describedby.
- The popup is portaled and re-applies the theme, brand and density of its field.
Tokens used: --border-width-default, --color-bg-subtle, --color-border-default, --color-surface-hover, --color-surface-hover-raised, --color-surface-pressed, --color-text-default, --color-text-disabled, --color-text-muted, --control-height-lg, --control-height-md, --control-height-sm, --control-padding-x-md, --control-padding-x-sm, --focus-ring-color, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-100, --font-size-200, --font-size-300, --font-tracking-wide, --font-weight-medium, --input-bg, --input-bg-disabled, --input-border, --input-border-hover, --input-border-invalid, --input-placeholder, --input-radius, --input-text, --layer-dropdown, --menu-item-height, --motion-duration-fast, --motion-duration-instant, --motion-easing-enter, --motion-easing-exit, --motion-easing-standard, --popup-bg, --popup-border, --popup-radius, --popup-shadow, --radius-inset, --size-20, --size-5, --size-96, --space-0-5, --space-1, --space-2, --space-3

### Field

Wires a visible label, an optional description and an error message to one form control. Ids, aria-describedby, aria-invalid and validation timing are handled by Base UI Field.

Import: `import { Field, useFieldContext } from '@bridge-ds/react'`
Accepts all native <div> attributes except onChange.
Props:
- label: ReactNode. Visible label. Required: placeholders are not labels.
- children: ReactNode. The control: Input, Textarea, Select, Combobox, NumberField, Slider.
- description?: ReactNode. Help shown under the label, before the control: format, constraints, privacy notes.
- error?: ReactNode. Error message. When set, the field is invalid and the message is announced through aria-describedby. Leave it unset to show the browser's validation message after a failed submit.
- required?: boolean = false. Marks the control as required (adds `required` to our controls and a visual marker to the label).
- disabled?: boolean
- name?: string. Name used by Form for submit values and server errors.
- validate?: (value: unknown) => string | string[] | null | undefined | Promise<string | string[] | null | undefined>. Custom validation. Return an error string (or strings) to mark the field invalid.
- validationMode?: 'onSubmit' | 'onBlur' | 'onChange'. When validation runs. Default `onSubmit` (inherits Form).
- hideLabel?: boolean = false. Visually hide the label (it stays the accessible name). Use only when the context labels the field, like a search bar.
When to use:
- Around every Input, Textarea, Select, Combobox, NumberField or Slider that does not take a label prop itself.
- To show a server-side error next to the right control (error prop, or Form errors by name).
- description for what the user needs before typing: format, limits, why you ask.
When not to use:
- Checkbox and Switch: they carry their own label next to the control.
- A group of checkboxes or radios: use CheckboxGroup or RadioGroup, which render a fieldset and legend.
Do:
- Description before, error after. The description answers questions before typing; the error appears under the control, where the eye already is.
Don't:
- Placeholder as the label. It disappears on the first keystroke and has low contrast. Keep a visible label.
Accessibility:
- The label is a native <label> bound to the control: clicking it focuses the control.
- description and error are linked through aria-describedby; an error also sets aria-invalid on the control.
- Errors carry an icon and text, never color alone (WCAG 1.4.1).
- required shows an asterisk (hidden from screen readers) and sets the native required attribute, which is announced.
- hideLabel keeps the label for assistive tech; use it only when the context labels the field visually (a search bar with an icon).
Tokens used: --border-width-1, --color-feedback-danger-icon, --color-feedback-danger-text, --color-text-default, --color-text-disabled, --color-text-muted, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-100, --font-size-200, --font-tracking-wide, --font-weight-medium, --space-0-5, --space-1, --space-1-5

### Form

A form with one validation pattern for the whole system: client checks on submit, server errors passed by field name, each shown under its Field and listed in a summary that takes focus.

Import: `import { Form, FormActions } from '@bridge-ds/react'`
Accepts all native <HTMLFormElement> attributes except onSubmit.
Props:
- errors?: Record<string, string | string[]>. Errors returned by the server after submit, keyed by field name. Each one shows under its Field and, with `errorSummary`, in a list at the top. A field's error clears when its value changes.
- onFormSubmit?: (values: Record<string, unknown>) => void. Called with the form values (keyed by name) when the form is valid and submitted.
- validationMode?: 'onSubmit' | 'onBlur' | 'onChange' = 'onSubmit'. When fields validate. Default `onSubmit`, then live once a field has been shown as invalid.
- errorSummary?: boolean = true. Show a summary of `errors` above the fields, with links that move focus to each field. Default true.
- errorSummaryTitle?: (count: number) => ReactNode = defaultTitle. Heading of the summary. Default "Fix {n} fields to continue".
- children?: ReactNode
When to use:
- Any group of fields submitted together: connect a repository, create a token, invite reviewers.
- errors for server-side validation, keyed by each Field's name.
- FormActions for the button row: primary action last.
When not to use:
- A single setting that saves on change: use Switch or an inline control without a form.
- Search and filter bars that update results live: no submit, no Form.
Do:
- Validate on submit, then live. Do not flag fields while the user is still typing for the first time. After a failed submit, errors clear as soon as the value is fixed.
- Keep what the user typed. Show the error next to the value so it can be corrected, never clear the field.
Don't:
- Disabling submit until the form is valid. A disabled button explains nothing. Keep it enabled and show what is missing on submit.
- One generic error at the top. "Something went wrong" sends users hunting. Attach each error to its field.
Accessibility:
- Base UI Form: native form element; fields validate on submit and the first invalid field gets focus.
- Server errors set aria-invalid on the control and are linked with aria-describedby.
- The error summary is role alert, receives focus when new errors arrive, and links move focus to each field.
- Errors are icon plus text, never color alone.
Tokens used: --border-width-1, --border-width-default, --color-feedback-danger-bg, --color-feedback-danger-border, --color-feedback-danger-icon, --color-feedback-danger-text, --color-text-default, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-200, --font-weight-semibold, --radius-container, --radius-inset, --space-0-5, --space-1, --space-2, --space-3, --space-4, --space-5

### Input

Single-line text field with optional prefix and suffix. Give it a label prop for a complete field, or place it inside a Field.

Import: `import { Input } from '@bridge-ds/react'`
Accepts all native <input> attributes except size, prefix.
Props:
- label?: string. Visible label. When set, the Input wraps itself in a Field (label, hint, error wired for you). Leave it unset when the Input is already inside a Field.
- hint?: ReactNode. Helper text under the label (Field description).
- error?: ReactNode. Error message. Marks the input invalid and announces the message.
- prefix?: ReactNode. Content before the value: an Icon, a currency symbol, a unit. Decorative unless it is text.
- suffix?: ReactNode. Content after the value: a unit, a Kbd hint, a clear IconButton.
- size?: 'sm' | 'md' | 'lg' = 'md'. Height follows the density tokens. Default `md`.
- hideLabel?: boolean. Visually hide the Field label (still the accessible name). Only with `label`.
- onValueChange?: (value: string) => void. Called with the new string value on every change.
When to use:
- Short free text: a name, a URL, a search query, a value with a unit.
- prefix for an icon or a currency, suffix for a unit or a keyboard hint.
- size follows density: sm in toolbars and filters, md in forms, lg only on sparse screens.
When not to use:
- More than one line: use Textarea.
- Numbers with steps, min and max: use NumberField (increment buttons, arrow keys, locale formatting).
- Choosing from a known list: Select for under about ten options, Combobox for long or searchable lists.
Do:
- Label above, format help in the hint. The label stays visible while typing; the hint answers the question before it is asked.
- Units in the suffix. The value stays a number the user can edit; the unit is context, not content.
Don't:
- Clearing the value on error. Keep what the user typed and say how to fix it.
Accessibility:
- Base UI Input: inherits label, description and error wiring from the surrounding Field.
- The field edge (color.border.strong) has at least 3:1 contrast with the surface in both themes (WCAG 1.4.11).
- Focus shows a 2px signal-colored edge on every focus, since users type into it.
- On touch screens the text is 17px so iOS does not zoom the page on focus.
- A prefix icon is decorative; if the prefix carries meaning (a currency), make it text.
Tokens used: --border-width-default, --color-bg-subtle, --color-border-default, --color-text-disabled, --color-text-muted, --control-height-lg, --control-height-md, --control-height-sm, --control-padding-x-md, --control-padding-x-sm, --focus-ring-color, --font-family-sans, --font-line-height-normal, --font-size-200, --font-size-300, --input-bg, --input-bg-disabled, --input-border, --input-border-hover, --input-border-invalid, --input-placeholder, --input-radius, --input-text, --motion-duration-instant, --motion-easing-standard, --space-2

### NumberField

Numeric input with decrease and increase buttons, arrow-key stepping, min and max, and locale-aware formatting.

Import: `import { NumberField } from '@bridge-ds/react'`
Props:
- label?: string. Visible label. When set, the NumberField wraps itself in a Field. Leave unset inside a Field.
- hint?: ReactNode
- error?: ReactNode
- value?: number | null
- defaultValue?: number
- onValueChange?: (value: number | null) => void
- min?: number
- max?: number
- step?: number. Amount for the buttons and arrow keys. Shift+arrow uses largeStep. Default 1.
- largeStep?: number
- format?: Intl.NumberFormatOptions. Intl number format, for example `{ style: 'unit', unit: 'pixel' }` or `{ maximumFractionDigits: 2 }`.
- suffix?: ReactNode. Unit or symbol shown after the value inside the box, for example "px".
- hideSteppers?: boolean = false. Hide the increment and decrement buttons (keyboard and wheel still work).
- size?: 'sm' | 'md' | 'lg' = 'md'
- name?: string
- placeholder?: string
- disabled?: boolean
- readOnly?: boolean
- required?: boolean
- hideLabel?: boolean
- id?: string
- className?: string
When to use:
- Exact numbers within known bounds: approvals, columns, spacing steps, retry counts.
- suffix for the unit (px, ms, %), format for currency or percent.
When not to use:
- Identifiers that only look like numbers (zip codes, ids, phone numbers): use Input with inputMode numeric.
- Rough values where position matters: use Slider.
Do:
- Set min and max. Bounds disable the buttons at the edges and stop impossible values.
Don't:
- Unit in the label and in the value. Say it once, in the suffix: "Base spacing" with px, not "Base spacing (px)" plus px.
Accessibility:
- Base UI NumberField: a text input with role textbox and aria-roledescription Number field; Arrow Up and Down step, Shift uses largeStep.
- The decrease and increase buttons are named and disabled at min and max.
- Inherits label, description and error from Field; invalid sets aria-invalid and a danger edge.
- Values use tabular figures so digits do not jump while stepping.
Tokens used: --border-width-default, --color-border-default, --color-surface-hover, --color-surface-pressed, --color-text-default, --color-text-disabled, --color-text-muted, --control-height-lg, --control-height-md, --control-height-sm, --control-padding-x-sm, --focus-ring-color, --focus-ring-width, --font-family-sans, --font-size-200, --font-size-300, --input-bg, --input-bg-disabled, --input-border, --input-border-hover, --input-border-invalid, --input-radius, --input-text, --motion-duration-instant, --motion-easing-standard, --space-2

### RadioGroup

One choice among a few visible, mutually exclusive options. Arrow keys move the selection; the group has a legend and optional per-option descriptions.

Import: `import { RadioGroup } from '@bridge-ds/react'`
Props:
- label: ReactNode. Group label, rendered as the fieldset legend. Ask the question the options answer.
- children: ReactNode. Radio items.
- description?: ReactNode. Help under the legend.
- error?: ReactNode. Error message for the group. Marks it invalid and is linked with aria-describedby.
- value?: string
- defaultValue?: string
- onValueChange?: (value: string) => void
- orientation?: 'vertical' | 'horizontal' = 'vertical'. Default `vertical`. Use `horizontal` only for two or three short options.
- name?: string
- disabled?: boolean
- readOnly?: boolean
- required?: boolean
- hideLabel?: boolean = false. Visually hide the legend (it stays the accessible name).
- className?: string
When to use:
- Two to about six exclusive options that benefit from being compared side by side.
- Options that need a description to choose well.
- horizontal only for two or three short options, like Light, Dark, System.
When not to use:
- More than about six options, or options users know by name: use Select.
- A single on or off: use Switch or Checkbox.
- Switching views of content: use Tabs.
Do:
- Preselect the safest or most common option. A default saves a decision; pick the one most teams want.
Don't:
- A single radio button. One radio cannot be unselected. Use a Checkbox.
Accessibility:
- Base UI RadioGroup: role radiogroup named by the legend; one tab stop, arrow keys move and select.
- Each radio is labelled by its text (aria-labelledby) and described by its description.
- The group error is linked with aria-describedby and sets aria-invalid; it shows an icon and text.
- Selected uses the ink fill with a light dot; the unselected edge is color.border.strong (3:1).
Tokens used: --border-width-1, --border-width-default, --checkbox-bg, --checkbox-bg-checked, --checkbox-border, --checkbox-fg-checked, --color-action-disabled-bg, --color-action-disabled-text, --color-border-default, --color-border-hover, --color-control-checked-bg-hover, --color-feedback-danger-icon, --color-feedback-danger-text, --color-text-default, --color-text-disabled, --color-text-muted, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-100, --font-size-200, --font-tracking-wide, --font-weight-medium, --input-border-invalid, --motion-duration-fast, --motion-duration-instant, --motion-easing-enter, --motion-easing-standard, --radius-pill, --size-4, --size-6, --space-0-5, --space-1, --space-1-5, --space-2, --space-6

### Select

Picks one value from a short, known list in a popup. The trigger looks like an Input so forms read as one family; the list supports groups, descriptions and disabled options.

Import: `import { Select } from '@bridge-ds/react'`
Accepts all native <button> attributes except value, defaultValue, onChange, prefix, children.
Props:
- options: SelectOption[]. The choices. Keep them under about ten; use Combobox for long or searchable lists.
- placeholder?: string = 'Select an option'. Text shown when nothing is selected. Describe the choice, not the action: "No theme".
- value?: string | null. Selected value (controlled). `null` means nothing selected.
- defaultValue?: string | null. Initially selected value (uncontrolled).
- onValueChange?: (value: string | null) => void. Called with the new value when the user picks an option.
- label?: string. Visible label. When set, the Select wraps itself in a Field. Leave unset inside a Field.
- hint?: ReactNode. Help under the label (Field description). Only with `label`.
- error?: ReactNode. Error message. Marks the select invalid and announces the message. Only with `label`.
- hideLabel?: boolean. Visually hide the Field label (it stays the accessible name).
- required?: boolean
- name?: string. Name for form submission (a hidden input carries the value).
- size?: 'sm' | 'md' | 'lg' = 'md'. Height follows the density tokens. Default `md`.
- prefix?: ReactNode. Content before the value, usually an Icon.
- open?: boolean. Popup open state (controlled).
- defaultOpen?: boolean. Open on first render (uncontrolled).
- onOpenChange?: (open: boolean) => void
When to use:
- One choice from about 4 to 10 known options: a theme, a brand, a token group, a role.
- The options are long words or labels that would crowd a RadioGroup.
- Space is tight: in a toolbar, a table filter or a dense settings row (size sm).
When not to use:
- Two or three options that should all stay visible: use RadioGroup, or an attached ButtonGroup for a view switch.
- Long or unknown lists (people, repositories, 20+ tokens): use Combobox so users can type to filter.
- Several values at once: use Combobox with multiple, or a CheckboxGroup.
- Actions (Duplicate, Delete): use Menu. A Select changes a value, it does not run commands.
Do:
- Order options by meaning, not alphabetically by default. Put the most common choice first, or follow the natural order (System, Light, Dark). Group long lists under short headings.
- Explain a disabled option. A description on the option says why it cannot be picked, instead of leaving users guessing.
Don't:
- A Select with two options. Hiding two choices behind a click costs a step for nothing. Show them as radios or a Switch.
- The placeholder as an instruction. "Click to select" says nothing. The placeholder describes the empty state: "No theme", "Any reviewer".
Accessibility:
- Base UI Select: the trigger is a combobox button with aria-expanded; the popup is a listbox with options marked aria-selected and aria-disabled.
- Keyboard: Enter, Space or Arrow keys open it; arrows move; typing jumps to a matching option; Enter selects; Escape closes and returns focus to the trigger.
- The Field label names the trigger; hint and error are linked through aria-describedby, and an error sets aria-invalid.
- The selected option shows a check mark and a medium weight, not color alone.
- The trigger edge uses color.border.strong (3:1 against surfaces) and the signal focus ring on keyboard focus.
- The popup is portaled and re-applies the theme, brand and density of its trigger.
Tokens used: --border-width-default, --color-bg-subtle, --color-border-default, --color-surface-hover-raised, --color-text-default, --color-text-disabled, --color-text-muted, --control-height-lg, --control-height-md, --control-height-sm, --control-padding-x-md, --control-padding-x-sm, --focus-ring-color, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-100, --font-size-200, --font-size-300, --font-tracking-wide, --font-weight-medium, --input-bg, --input-bg-disabled, --input-border, --input-border-hover, --input-border-invalid, --input-placeholder, --input-radius, --input-text, --layer-dropdown, --menu-item-height, --motion-duration-fast, --motion-duration-instant, --motion-easing-enter, --motion-easing-exit, --motion-easing-standard, --popup-bg, --popup-border, --popup-radius, --popup-shadow, --radius-inset, --size-96, --space-1, --space-2

### Slider

Picks a value or a range by position on a continuous scale, with the formatted value shown at the end of the track.

Import: `import { Slider } from '@bridge-ds/react'`
Props:
- label?: string. Visible label. When set, the Slider wraps itself in a Field. Leave unset inside a Field.
- hint?: ReactNode
- error?: ReactNode
- value?: number | number[]. A number for one thumb, an array of two numbers for a range.
- defaultValue?: number | number[]
- onValueChange?: (value: number | number[]) => void. Fires while dragging.
- onValueCommitted?: (value: number | number[]) => void. Fires once when the user releases the thumb or stops pressing keys. Use it for expensive work.
- min?: number
- max?: number
- step?: number
- largeStep?: number. Step for Page Up, Page Down and Shift+arrow. Default 10.
- format?: Intl.NumberFormatOptions. Intl format for the displayed value, for example `{ style: 'percent' }`.
- showValue?: boolean = true. Show the formatted value at the end of the track. Default true.
- thumbLabels?: string[]. Accessible names of the thumbs when there is no visible label, or to name range ends.
- name?: string
- disabled?: boolean
- hideLabel?: boolean
- className?: string
When to use:
- Approximate values where the position matters more than the exact number: opacity, threshold, zoom.
- A range with two thumbs: pass an array of two numbers.
- Live previews: update on onValueChange, save on onValueCommitted.
When not to use:
- An exact number the user knows: use NumberField.
- Very large ranges where one step is tiny: pair it with a NumberField or use NumberField alone.
Do:
- Show the value and its unit. format makes the value readable: 48%, 4.5, 16 px.
Don't:
- A slider for precise input. Dragging to exactly 37 is frustrating. Use NumberField when the exact number matters.
Accessibility:
- Base UI Slider: each thumb is a native range input with role slider, aria-valuenow and aria-valuetext from format.
- Arrow keys step, Page Up and Page Down or Shift+arrow use largeStep, Home and End jump to min and max.
- Range thumbs are named "<label> minimum" and "<label> maximum"; override with thumbLabels.
- The filled range uses the signal color (3:1 on the muted track); focus rings the thumb.
Tokens used: --border-width-strong, --color-border-strong, --color-text-default, --color-text-disabled, --elevation-raised, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-size-200, --input-border-invalid, --motion-duration-instant, --motion-easing-standard, --radius-pill, --size-20, --size-4, --size-6, --slider-range, --slider-thumb, --slider-track, --space-1, --space-4

### Switch

Turns a setting on or off with immediate effect, like a light switch. No save button needed.

Import: `import { Switch } from '@bridge-ds/react'`
Props:
- label: ReactNode. Visible label. Name the setting, not the state: "Email notifications", not "On".
- description?: ReactNode. Secondary text under the label, linked with aria-describedby.
- checked?: boolean
- defaultChecked?: boolean
- onCheckedChange?: (checked: boolean) => void
- labelPlacement?: 'end' | 'start' = 'end'. `end` puts the label after the switch; `start` puts it first and pushes the switch to the row end (settings lists). Default `end`.
- size?: 'sm' | 'md' = 'md'. Default `md` (36 by 20). `sm` (28 by 16) for dense tables.
- name?: string
- value?: string
- disabled?: boolean
- readOnly?: boolean
- required?: boolean
- id?: string
- className?: string
When to use:
- Settings that apply as soon as they are flipped: notifications, auto-publish, preview mode.
- labelPlacement start in settings lists, so labels align left and switches align right.
When not to use:
- A choice saved later with a form button: use Checkbox.
- More than two states: use RadioGroup or Select.
- Starting an action (Publish, Sync): use Button.
Do:
- Name the setting, not the state. The switch shows on or off; the label says what is on or off.
Don't:
- Labels that change with the state. "On" and "Off" labels repeat what the control already shows and hide what it controls.
- A switch that waits for Save. Users expect a switch to act at once. Inside a form that saves later, use a Checkbox.
Accessibility:
- Base UI Switch: role switch with aria-checked; Space and Enter toggle it; the label row is the click target.
- The off track uses control.track (3:1 against surfaces) and the thumb keeps 3:1 against the track, so the state is visible without color.
- The thumb position carries the state, not only the track color.
- If flipping it fails (network), revert it and say why next to it.
Tokens used: --color-bg-muted, --color-border-hover, --color-border-strong, --color-control-checked-bg-hover, --color-text-default, --color-text-disabled, --color-text-muted, --elevation-raised, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-size-100, --font-size-200, --font-tracking-wide, --motion-duration-fast, --motion-easing-standard, --radius-pill, --size-3, --size-4, --size-5, --size-6, --size-7, --size-9, --space-0-5, --space-3, --switch-thumb, --switch-thumb-on, --switch-track-off, --switch-track-on

### Textarea

Multi-line text that grows with its content up to a maximum, with an optional character count. Give it a label prop or place it inside a Field.

Import: `import { Textarea } from '@bridge-ds/react'`
Accepts all native <HTMLTextAreaElement> attributes.
Props:
- label?: string. Visible label. When set, the Textarea wraps itself in a Field. Leave unset inside a Field.
- hint?: ReactNode. Helper text under the label.
- error?: ReactNode. Error message. Marks the textarea invalid and announces the message.
- rows?: number = 3. Minimum visible lines. Default 3.
- maxRows?: number = 12. Grow with the content up to this many lines, then scroll. Default 12.
- autosize?: boolean = true. Grow with the content. Default true. When false the user can resize vertically.
- showCount?: boolean = false. Show "used / maxLength" under the field. Needs maxLength.
- hideLabel?: boolean. Visually hide the Field label. Only with `label`.
- onValueChange?: (value: string) => void. Called with the new string value on every change.
When to use:
- Free text longer than one line: pull request summaries, release notes, review comments.
- showCount with maxLength when there is a hard limit.
When not to use:
- A short single value: use Input.
- Structured content (lists of tokens, key-value pairs): use dedicated fields.
Do:
- Size it for the expected answer. rows sets the starting height; autosize grows it, so short boxes do not hide long answers.
Don't:
- Hiding the limit until it is hit. Show the count while typing so users can edit before the error.
Accessibility:
- Native textarea through Base UI Field: label, description and error are wired for you.
- Focus shows a 2px signal edge; invalid sets aria-invalid and a danger edge with a text error.
- The visual counter is hidden from screen readers to avoid announcing every keystroke; maxLength is enforced natively.
- On touch screens the text is 17px so iOS does not zoom on focus.
Tokens used: --border-width-default, --color-bg-subtle, --color-border-default, --color-text-disabled, --color-text-muted, --control-padding-x-md, --focus-ring-color, --font-family-sans, --font-line-height-normal, --font-size-100, --font-size-200, --font-size-300, --font-tracking-wide, --input-bg, --input-bg-disabled, --input-border, --input-border-hover, --input-border-invalid, --input-placeholder, --input-radius, --input-text, --motion-duration-instant, --motion-easing-standard, --space-1, --space-2

### Alert

An inline message about the state of a page or a section: a failed publish, a pending review, a sync in progress. It stays until the situation changes or the user dismisses it.

Import: `import { Alert } from '@bridge-ds/react'`
Accepts all native <div> attributes except title.
Props:
- tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger' = 'info'. Meaning of the message. The icon and the text carry it too, never the color alone. Default `info`.
- title?: ReactNode. Short summary in bold. Optional when the description is one short sentence.
- icon?: LucideIcon | false. Replace the tone icon, or pass `false` to hide it.
- actions?: ReactNode. Buttons or links under the text, for example "Review changes". Keep to two.
- onDismiss?: () => void. Shows a dismiss button and calls this when it is pressed.
- dismissLabel?: string = 'Dismiss'. Accessible name of the dismiss button. Default "Dismiss".
- announce?: 'auto' | 'off' = 'auto'. How assistive tech hears the alert when it appears after page load. `auto` uses role="alert" (interrupts) for danger and warning and role="status" (polite) for the others; `off` renders static content with no live region, right for alerts present when the page loads. Default `auto`.
When to use:
- A condition that affects what the user is doing here: a failed build, a protected branch, contrast warnings.
- Tone follows meaning: danger for failed or blocked, warning for needs attention, success for a finished task that changed this page, info for context, neutral when there is no judgement.
- Inside a form or card, above the content it is about.
When not to use:
- Confirming an action the user just took (Copied, Saved): use a Toast.
- An error on one field: use the Field error under that control.
- A decision the user must make before continuing: use an AlertDialog.
Do:
- Title says what happened, description says what to do. Users scan the bold line and read on only when they need to act.
- Give the way out as an action. A button next to the problem saves a search through the page.
Don't:
- Stacking several alerts. Three alerts in a row read as noise. Merge them, or keep the most severe and link to the rest.
- Danger for something that is not broken. Red trains people to ignore red. Use info or warning when nothing failed.
Accessibility:
- Alerts that appear after load are announced: danger and warning with role="alert" (interrupts), the others with role="status" (polite).
- Alerts present when the page loads should use announce="off": a live region on load is either ignored or read twice.
- Every tone has its own icon and the text carries the meaning, so color is never the only signal (WCAG 1.4.1).
- The dismiss button is a named IconButton (dismissLabel, default "Dismiss"). Move focus to a sensible place after dismissing if the alert had focus.
- Feedback text and icon colors are checked against their tinted backgrounds in both themes.
Tokens used: --alert-radius, --border-width-default, --color-bg-subtle, --color-border-default, --color-feedback-danger-bg, --color-feedback-danger-border, --color-feedback-danger-icon, --color-feedback-info-bg, --color-feedback-info-border, --color-feedback-info-icon, --color-feedback-success-bg, --color-feedback-success-border, --color-feedback-success-icon, --color-feedback-warning-bg, --color-feedback-warning-border, --color-feedback-warning-icon, --color-text-default, --color-text-muted, --font-family-sans, --font-line-height-normal, --font-size-200, --font-weight-semibold, --space-1, --space-2, --space-3, --space-4

### EmptyState

Explains why a view has no content yet and invites the action that fills it. An empty screen is a starting point, not a dead end.

Import: `import { EmptyState } from '@bridge-ds/react'`
Accepts all native <div> attributes except title.
Props:
- title: ReactNode. What is empty, said plainly: "No pull requests yet".
- icon?: LucideIcon. A Lucide icon that names the object that is missing (GitPullRequest for pull requests).
- description?: ReactNode. Why it is empty and what fills it. One or two sentences.
- actions?: ReactNode. The next step: usually one primary Button, plus an optional secondary Button or Link.
- size?: 'sm' | 'md' | 'lg' = 'md'. `sm` inside a card or table, `md` in a panel, `lg` for a whole page. Default `md`.
- headingLevel?: 2 | 3 | 4 | 5 | 6 = 3. Heading level of the title, to fit the page outline. Default 3.
- align?: 'center' | 'start' = 'center'. `center` for pages and panels, `start` inside left-aligned layouts. Default `center`.
When to use:
- First use: nothing has been created yet (no tokens, no pull requests). Explain the value and offer the first step.
- No results: a search or filter matched nothing. Say what was searched and how to widen it.
- Cleared: the user finished everything (no reviews waiting). Confirm it calmly.
- size sm inside tables and cards, md in panels, lg for a whole page.
When not to use:
- Errors that stopped content from loading: use an Alert with a retry action.
- Content still loading: use Skeleton.
Do:
- Say what goes here and how to get it. The title names the missing object, the description says where it comes from, the button starts it.
Don't:
- "Nothing here" with no way forward. It describes the screen the user already sees and leaves them stuck.
Accessibility:
- The title is a real heading (headingLevel, default h3) so it appears in the page outline; pick the level that fits the page.
- The icon is decorative and hidden from assistive tech: the title carries the meaning.
- Actions are regular Buttons or Links in the tab order, after the text.
Tokens used: --border-width-default, --color-border-default, --color-surface-default, --color-text-default, --color-text-muted, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-line-height-tight, --font-size-200, --font-size-300, --font-size-400, --font-size-600, --font-tracking-snug, --font-tracking-tight, --font-weight-semibold, --radius-container, --radius-control, --size-10, --size-12, --size-14, --space-1, --space-1-5, --space-10, --space-16, --space-2, --space-3, --space-4, --space-5, --space-6

### Progress

Shows how far a task has gone: a linear bar in layouts, a circular ring in compact spots. Indeterminate when the duration is unknown.

Import: `import { Progress } from '@bridge-ds/react'`
Accepts all native <div> attributes except children.
Props:
- value: number | null. Current value. `null` means indeterminate (the duration is unknown).
- min?: number = 0
- max?: number = 100
- label?: ReactNode. Visible label, for example "Publishing tokens". Without it, pass aria-label.
- showValue?: boolean. Show the formatted value ("64%") next to the label, or inside a large ring. Default true for linear with a label.
- variant?: 'linear' | 'circular' = 'linear'. `linear` bar for tasks in a layout; `circular` ring for compact spots. Default `linear`.
- size?: 'sm' | 'md' | 'lg' = 'md'. Bar thickness or ring diameter. Default `md`.
- tone?: 'signal' | 'success' | 'warning' | 'danger' = 'signal'. Signal color while working; switch to success or danger when the task ends. Default `signal`.
- format?: Intl.NumberFormatOptions. Intl number format for the value text. Default: percent of the range.
- getAriaValueText?: (formattedValue: string, value: number | null) => string. Human-readable value for screen readers, for example "12 of 40 tokens".
When to use:
- Tasks that take more than about two seconds and report progress: publishing, importing from Figma, running contrast checks.
- Indeterminate (value null) when the task runs but its length is unknown.
- Switch tone to success or danger when the task ends, and say so in the label.
When not to use:
- Short waits or busy controls: use Spinner (or Button loading).
- Content that is loading into a layout: use Skeleton in the shape of the content.
- A static measurement like storage used or a score: that is a meter, not progress.
Do:
- Label the task, show the value. "Publishing tokens 64%" tells users what is happening and how long is left.
- Use counts when they mean more than percent. getAriaValueText and the label can say "12 of 40 pairs".
Don't:
- A progress bar that never moves. If you cannot report progress, use the indeterminate state rather than a fake value.
Accessibility:
- Base UI Progress: role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax; indeterminate drops aria-valuenow.
- Needs a name: the label prop, or aria-label when there is no visible label (rings).
- The visible value text is aria-hidden; screen readers get it from aria-valuetext (customize with getAriaValueText).
- The fill uses the signal color with at least 3:1 against the track and surfaces.
- Under reduced motion the indeterminate bar stops sliding and shows a full, softened track instead.
Tokens used: --color-feedback-danger-icon, --color-feedback-success-icon, --color-feedback-warning-icon, --color-text-default, --color-text-muted, --font-family-sans, --font-line-height-normal, --font-size-100, --font-size-200, --font-weight-medium, --motion-duration-moderate, --motion-duration-shimmer, --motion-duration-spin, --motion-easing-standard, --progress-fill, --progress-track, --radius-pill, --size-14, --size-5, --size-8, --space-1, --space-1-5, --space-2, --space-3

### Skeleton

A placeholder in the shape of content that is loading, so the layout does not jump when the data arrives.

Import: `import { Skeleton } from '@bridge-ds/react'`
Accepts all native <span> attributes.
Props:
- variant?: 'text' | 'circle' | 'rect' = 'text'. `text` draws lines at the current font size; `circle` an avatar; `rect` a block (thumbnail, chart, card). Default `text`.
- lines?: number = 1. Number of text lines. The last one is shorter, like a real paragraph. Default 1.
- width?: string. CSS width, for example `"12ch"` or `"100%"`. Defaults: text 100%, circle 40px, rect 100%.
- height?: string. CSS height for `rect` and `circle`.
- static?: boolean. Stop the shimmer (for example in long lists). Reduced motion stops it everywhere.
When to use:
- First load of a list, table, card or detail panel whose shape you know.
- Mirror the real layout: a circle for an avatar, short lines for names, a block for a preview.
- Pair with aria-busy="true" on the region and a visually hidden "Loading tokens".
When not to use:
- Inside a button or after a click on a control: use Button loading or Spinner.
- Long tasks with known progress: use Progress.
- When loading takes under about 300 ms: show nothing, the flash is worse than the wait.
Do:
- Match the shape of the real content. The page settles in place instead of reflowing when data lands.
Don't:
- A whole page of shimmering blocks. Load the frame (navigation, titles) for real and use skeletons only where the data goes.
Accessibility:
- Skeletons are always aria-hidden: they are decoration, not content.
- Put aria-busy="true" on the loading region and a VisuallyHidden label like "Loading tokens"; remove both when the content arrives.
- The shimmer stops under prefers-reduced-motion; use static for long lists.
Tokens used: --motion-duration-shimmer, --motion-easing-standard, --radius-container, --radius-pill, --size-10, --size-20, --skeleton-base, --skeleton-radius, --skeleton-shine

### Spinner

An indeterminate activity indicator for short waits and busy controls. For content that is loading into a layout, use Skeleton; for long tasks, use Progress.

Import: `import { Spinner } from '@bridge-ds/react'`
Accepts all native <span> attributes.
Props:
- size?: 'xs' | 'sm' | 'md' | 'lg' = 'sm'. Token size: xs 12, sm 16, md 20, lg 24. Default `sm`.
- tone?: 'current' | 'signal' = 'current'. `current` inherits the text color (inside buttons); `signal` uses the signal color. Default `current`.
- label?: string. Accessible label announced as a status, for example "Loading tokens". Omit when the spinner sits inside a control that already exposes aria-busy: it is then hidden from assistive tech.
When to use:
- Inside a control that is working: Button and IconButton show it automatically with loading.
- Small regions that refresh in place, for waits up to a few seconds.
- tone signal when it stands alone, current when it sits in text or a control.
When not to use:
- First load of a list or a page: use Skeleton in the shape of the content.
- Tasks with measurable progress or that take longer than a few seconds: use Progress.
Do:
- Name what is loading. A standalone spinner needs a label so screen readers hear "Loading tokens".
Don't:
- A spinner in the middle of an empty page. It hides the layout and makes the wait feel longer. Show the frame and use skeletons.
Accessibility:
- With label it renders role="status" and is announced; without label it is aria-hidden (use it inside a control that sets aria-busy).
- Under reduced motion it keeps turning, slower: a frozen spinner looks like a crash.
- The arc is drawn in currentColor or the signal color, which keeps 3:1 against surfaces.
Tokens used: --color-signal-default, --icon-size-lg, --icon-size-md, --icon-size-sm, --icon-size-xs, --motion-duration-spin

### Toast

A short, temporary notification that confirms what just happened (Tokens published, Link copied) without interrupting. Shown with useToast() under a ToastProvider.

Import: `import { Toast, useToast, ToastProvider } from '@bridge-ds/react'`
Props:
- title: ReactNode. One short line: what happened. "Tokens published".
- description?: ReactNode. Optional detail: what changed or what to do next.
- tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger' | 'loading'. Icon and meaning. Default `neutral`. `loading` shows a spinner and never times out.
- action?: ToastAction. One action, for example Undo. Toasts with an action stay until dismissed or acted on.
- timeout?: number. Milliseconds before auto dismiss. `0` keeps it until closed. Default 5000 (0 with an action or for loading).
- priority?: 'low' | 'high'. `high` interrupts screen readers (assertive). Default `high` for danger, `low` otherwise.
- id?: string. Reuse an id to update a toast in place.
- onClose?: () => void. Called when the toast closes (timeout, swipe, close button or action).
When to use:
- Confirming an action the user just took, especially when its result is not visible on screen: Copied, Published, Invitation sent.
- Offering a quick reversal: Token deleted, with Undo.
- Background work that finishes later: toast.promise() shows loading, then success or error.
When not to use:
- Information the user must read or act on to continue: use an inline Alert or an AlertDialog.
- Errors in a form: show them next to the fields.
- Anything that needs more than one action or a long explanation.
Do:
- Use the verb of the action, in the past tense. The button said Publish, the toast says Published. The vocabulary follows the flow.
- Offer Undo for destructive actions. A toast with an action stays until the user acts or dismisses it, so nobody misses the way back.
Don't:
- A toast for every click. If the result is visible on screen, the screen already confirms it.
Accessibility:
- Toasts render in a labelled region ("Notifications") that users reach with F6. Hovering or focusing the stack expands it and pauses auto dismiss.
- Titles and descriptions are announced through a live region: politely by default, assertively for danger (priority high).
- Toasts with an action, loading toasts and danger toasts from promise() do not auto dismiss, so screen reader and keyboard users have time to act.
- Close and action buttons are real buttons with names; the close button is labelled "Dismiss notification".
- Swipe right or down dismisses on touch. Under reduced motion toasts appear and leave without sliding.
Tokens used: --border-width-default, --color-feedback-danger-icon, --color-feedback-info-icon, --color-feedback-success-icon, --color-feedback-warning-icon, --color-text-default, --color-text-muted, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-size-200, --font-weight-semibold, --layer-toast, --motion-duration-moderate, --motion-duration-slow, --motion-easing-enter, --motion-easing-exit, --motion-easing-standard, --size-96, --space-0-5, --space-1-5, --space-2, --space-3, --space-4, --space-6, --toast-bg, --toast-border, --toast-radius, --toast-shadow

### AlertDialog

Stops the user to confirm an action with consequences, usually destructive: delete tokens, revoke access, force publish. It asks one question and offers two answers.

Import: `import { AlertDialog } from '@bridge-ds/react'`
Props:
- open?: boolean
- defaultOpen?: boolean
- onOpenChange?: (open: boolean) => void
- children?: ReactNode. AlertDialogTrigger and AlertDialogContent.
When to use:
- Before an action that cannot be undone or that affects other people: deleting, revoking, overwriting.
- tone primary for consequential but safe actions, like publishing a release to every consumer.
When not to use:
- Actions that can be undone: do them right away and offer Undo in a Toast.
- Collecting input: use Dialog.
- Routine saves: confirmation fatigue teaches people to click through without reading.
Do:
- Ask a question that names the action and the object. "Delete 3 tokens?" with a confirm button "Delete tokens" is understood without reading the description.
- Say the consequence, including what cannot be undone. "Components that use them fall back to their defaults. This cannot be undone."
Don't:
- "Are you sure?" with Yes and No. It hides what is about to happen. Name the action in the title and on the button.
Accessibility:
- Base UI AlertDialog: role alertdialog, named by the title, described by the consequence.
- Focus starts on Cancel, the safe choice, so a reflexive Enter does not destroy anything.
- It does not close on an outside click; Escape and Cancel close it and focus returns to the trigger.
- While loading, the confirm button keeps focus and announces aria-busy.
Tokens used: --layout-inset, --space-2, --space-3

### Dialog

A modal window for a short, focused task that must be finished or cancelled before the user goes back to the page: rename a token, invite a reviewer, review a release.

Import: `import { Dialog } from '@bridge-ds/react'`
Props:
- open?: boolean. Controlled open state.
- defaultOpen?: boolean. Initial open state when uncontrolled.
- onOpenChange?: (open: boolean) => void. Called when the dialog opens or closes (Escape, scrim click, close button, DialogClose).
- disablePointerDismissal?: boolean = false. Keep the dialog open when the user clicks the scrim. Use for forms with unsaved input.
- children?: ReactNode. DialogTrigger and DialogContent.
When to use:
- A task of one to three fields that belongs to the current page and needs the user's full attention.
- Reviewing something before committing it, like release notes before Publish.
- Size sm for one field, md (default) for short forms, lg for reading or comparing content.
When not to use:
- Confirming a destructive action: use AlertDialog, which cannot be dismissed by clicking outside.
- Details the user wants to read next to the page, like a token's history: use Drawer.
- A hint or a small menu anchored to a control: use Popover or Menu.
- Long multi-step flows: give them a page. Stacked dialogs lose people.
Do:
- Title says the task, the primary button repeats the verb. "Rename token" opens a dialog titled "Rename token" whose primary action is "Rename token". Users always know where they are.
- Keep the footer for actions only. Alternatives first, primary last, aligned to the end. Explanations go in the description or the body.
Don't:
- A dialog for information the page could show inline. Interrupting to say "Saved" costs a click. Use a Toast or an inline Alert.
- Vague footer labels. "OK" and "Yes" make users reread the title to know what they agree to.
Accessibility:
- Base UI Dialog: role dialog, aria-modal, named by the title and described by the description.
- Focus moves into the dialog when it opens (the first focusable element, or initialFocus) and is trapped there.
- Escape, the close button and a click on the scrim close it; focus returns to the trigger.
- The page behind is inert and scroll is locked while the dialog is open.
- The body scrolls on its own when content is long, so the title and the actions stay visible.
- Motion is a 200ms fade and a slight rise; with reduced motion it appears instantly.
Tokens used: --border-width-default, --color-bg-subtle, --color-border-default, --color-text-default, --color-text-muted, --control-height-sm, --dialog-bg, --dialog-radius, --dialog-scrim, --dialog-shadow, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-200, --font-size-400, --font-tracking-snug, --font-weight-semibold, --layer-modal, --layer-overlay, --layout-inset, --motion-duration-fast, --motion-duration-moderate, --motion-easing-enter, --motion-easing-exit, --motion-easing-standard, --size-140, --size-180, --size-96, --space-1, --space-2, --space-3, --space-4, --space-6

### Drawer

A sheet that slides in from an edge and keeps the page in view behind it: token details, filters, a review checklist. On touch it can be swiped away.

Import: `import { Drawer } from '@bridge-ds/react'`
Props:
- open?: boolean
- defaultOpen?: boolean
- onOpenChange?: (open: boolean) => void
- side?: 'start' | 'end' | 'bottom' = 'end'. Edge the sheet slides from. `end` (default) for details and filters, `start` for navigation, `bottom` on small screens.
- disablePointerDismissal?: boolean = false. Keep it open when the scrim is clicked.
- children?: ReactNode. DrawerTrigger and DrawerContent.
When to use:
- Details or editing for one item of a list or table, where the user wants to keep the list in mind.
- Filters and settings for the current view.
- side end (default) for details, start for navigation, bottom for small screens and quick choices.
When not to use:
- A short task that needs full attention: use Dialog.
- Content that should stay visible while the user works on the page: use a non-modal side panel in the layout.
Do:
- Title it with the item's name. "color.signal.default" tells the user which row they opened. The description says what it is for.
- Put the actions in the footer. The footer stays visible while the body scrolls.
Don't:
- A drawer that opens another drawer. Stacked sheets hide where the user came from. Navigate inside the drawer or use a page.
Accessibility:
- Base UI Drawer: modal dialog semantics, named by the title, focus trapped, Escape closes, focus returns to the trigger.
- Swipe to dismiss is an extra for touch, never the only way: the close button and Escape always work.
- The page behind is inert while the drawer is open.
- Slides in 300ms with an exponential ease-out; reduced motion shows it instantly.
- start and end follow the physical left and right in v1; RTL layouts should pass the mirrored side.
Tokens used: --border-width-default, --color-bg-subtle, --color-border-default, --color-border-strong, --color-text-default, --color-text-muted, --dialog-scrim, --drawer-bg, --drawer-border, --drawer-radius, --drawer-shadow, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-200, --font-size-400, --font-tracking-snug, --font-weight-semibold, --layer-modal, --layer-overlay, --layout-inset, --motion-duration-instant, --motion-duration-moderate, --motion-duration-slow, --motion-easing-enter, --motion-easing-exit, --radius-pill, --size-10, --size-120, --size-160, --size-80, --space-1, --space-12, --space-2, --space-24, --space-3

### Menu

A list of actions or options that opens from a button: row overflow actions, view options, sort order. Supports icons, shortcuts, groups, checkable items and submenus.

Import: `import { Menu, MenuRadioGroup, MenuGroup, MenuSeparator, MenuSubmenu } from '@bridge-ds/react'`
Props:
- open?: boolean
- defaultOpen?: boolean
- onOpenChange?: (open: boolean) => void
- loopFocus?: boolean = true. Wrap focus from the last item to the first. Default true.
- children?: ReactNode. MenuTrigger and MenuContent.
When to use:
- More than two or three actions on one item: show the main one as a button and the rest in a Menu.
- View options: checkable items for columns, a radio group for sort order.
- A submenu for one level of grouping (Move to group, then the groups).
When not to use:
- Choosing a value for a form field: use Select or Combobox, which work with Field and forms.
- Navigation between pages: use links in the sidebar or tabs.
- Two actions: show both as buttons.
Do:
- Destructive items last, after a separator. Distance from routine actions prevents slips. The danger tone backs up the verb.
- Icons on all items or on none. Labels stay aligned either way, but a mix reads as noise.
Don't:
- Submenus inside submenus. Two levels deep loses keyboard and pointer users alike. Flatten with groups.
Accessibility:
- Base UI Menu: the trigger has aria-haspopup and aria-expanded; the popup is role menu with menuitem, menuitemcheckbox and menuitemradio children.
- ArrowDown or Enter opens it and focuses the first item; arrows move, Home and End jump, typing a letter jumps to a matching item.
- Enter and Space activate; checkable items toggle aria-checked and keep the menu open.
- ArrowRight opens a submenu, ArrowLeft and Escape close it; Escape on the root returns focus to the trigger.
- Shortcut hints are visual only (aria-hidden); bind the shortcut yourself and document it elsewhere.
- Disabled items stay in the list so users learn the action exists, but cannot be activated.
Tokens used: --border-width-default, --focus-ring-color, --focus-ring-width, --font-family-mono, --font-family-sans, --font-line-height-snug, --font-size-100, --font-size-200, --font-tracking-wide, --font-weight-medium, --icon-size-sm, --layer-dropdown, --menu-item-bg-highlight, --menu-item-height, --menu-item-indicator, --menu-item-text, --menu-item-text-danger, --menu-item-text-disabled, --menu-item-text-muted, --menu-separator, --motion-duration-fast, --motion-easing-enter, --motion-easing-exit, --popup-bg, --popup-border, --popup-radius, --popup-shadow, --radius-inset, --radius-pill, --size-60, --size-80, --space-1, --space-1-5, --space-2, --space-4

### Popover

A small non-modal panel anchored to a trigger, for extra detail or a quick choice that does not deserve a dialog: who must review, what a check does, branch info.

Import: `import { Popover } from '@bridge-ds/react'`
Props:
- open?: boolean
- defaultOpen?: boolean
- onOpenChange?: (open: boolean) => void
- modal?: boolean = false. Trap focus and block the page while open. Default false: popovers are light, non-modal surfaces.
- children?: ReactNode. PopoverTrigger and PopoverContent.
When to use:
- Explaining something next to the control it is about, when the text is too long for a tooltip or contains a link.
- A small form or a couple of actions tied to one control.
- Info icons: Popover (click or openOnHover) instead of Tooltip, so touch and screen reader users can reach it.
When not to use:
- A list of actions: use Menu, which has the right keyboard model.
- A label for an icon button: use Tooltip.
- A task that must be completed or cancelled: use Dialog.
Do:
- Keep it short and anchored. A title, two sentences, at most one or two actions. The arrow shows what it belongs to.
Don't:
- Forms that span several sections. A popover closes on an outside click, so long input gets lost. Use a Dialog or a Drawer.
Accessibility:
- Base UI Popover: the trigger exposes aria-expanded and aria-controls; the popup is a dialog named by its title.
- Focus moves into the popup when it opens and back to the trigger when it closes. Escape and outside clicks close it.
- Non-modal by default: the page stays usable. Set modal when the popover holds a small form.
- It flips and shifts to stay in the viewport; the arrow follows the anchor.
Tokens used: --border-width-default, --color-text-default, --color-text-muted, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-200, --font-weight-semibold, --layer-popover, --motion-duration-fast, --motion-easing-enter, --motion-easing-exit, --popup-bg, --popup-border, --popup-radius, --popup-shadow, --size-80, --space-1-5, --space-2, --space-3, --space-4

### Tooltip

A short label that appears on hover and keyboard focus. It names icon buttons and reveals truncated text for sighted users; the accessible name always lives on the trigger.

Import: `import { Tooltip, TooltipProvider } from '@bridge-ds/react'`
Props:
- content: ReactNode. The tooltip text. Short, plain, no interactive content.
- children: ReactElement. The trigger: a focusable element that forwards its ref and spreads props (Button, IconButton, Link).
- side?: 'top' | 'bottom' | 'left' | 'right' = 'top'. Preferred side. Default `top`.
- align?: 'start' | 'center' | 'end' = 'center'
- delay?: number. Hover delay in ms for this tooltip; otherwise the TooltipProvider delay applies.
- open?: boolean
- defaultOpen?: boolean
- onOpenChange?: (open: boolean) => void
- disabled?: boolean = false. Turn the tooltip off without unwrapping the trigger.
- className?: string
When to use:
- Every IconButton, with the same text as its label.
- Truncated text, to show the full value.
- Wrap the app once in TooltipProvider so neighbouring tooltips open instantly after the first one.
When not to use:
- Information users need to complete a task: show it inline or in a Popover. Tooltips are invisible on touch screens.
- Interactive content (links, buttons): tooltips cannot be reached. Use Popover.
- Repeating a visible label: it adds noise.
Do:
- Match the trigger's label. IconButton label="Copy token name" and the tooltip "Copy token name": what users see and hear is the same.
Don't:
- Tooltips on disabled buttons to explain why. Disabled buttons do not receive focus, so keyboard users never see it. Explain inline instead.
Accessibility:
- Base UI treats tooltips as visual labels: the trigger must carry the accessible name (IconButton label), which screen readers announce. The popup is not announced.
- Opens on hover after the provider delay (500ms) and immediately on keyboard focus; Escape dismisses it.
- The trigger must be focusable: wrap a Button, IconButton or Link, never plain text.
- Inverse surface (tooltip.bg, tooltip.text) passes 4.5:1 in both themes.
Tokens used: --elevation-overlay, --font-family-sans, --font-line-height-snug, --font-size-100, --font-tracking-wide, --font-weight-medium, --layer-tooltip, --motion-duration-fast, --motion-duration-instant, --motion-easing-enter, --motion-easing-exit, --size-72, --space-1, --space-2, --tooltip-bg, --tooltip-radius, --tooltip-text

### Breadcrumb

Shows where the current page sits in the library hierarchy and links back to each level above it.

Import: `import { Breadcrumb } from '@bridge-ds/react'`
Props:
- items: BreadcrumbItemData[]. Path from the top of the hierarchy to the current page. The last item is marked as the current page.
- maxItems?: number. Accessible name of the navigation landmark. Default "Breadcrumb". */ 'aria-label'?: string; /** Collapse long paths: keep the first item and the last `maxItems - 1`, hide the middle behind a "Show path" button. Default: no collapsing.
When to use:
- Pages two or more levels deep in a hierarchy: a token inside a group, a component inside a family.
- At the top of the page, above the page title.
- maxItems on deep paths so the trail stays on one line; the middle levels stay one click away.
When not to use:
- Flat sites or the top level: there is nothing to go back up to.
- Showing the steps of a flow: use a stepper. A breadcrumb shows location, not progress.
- As the only navigation: it complements SidebarNav, it does not replace it.
Do:
- Mirror the hierarchy, not the history. Each item is a parent of the next, whatever route the user took to get here.
Don't:
- Linking the current page. The last item is where the user already is. It is text with aria-current, not a link.
Accessibility:
- A nav landmark named "Breadcrumb" (override with aria-label), with an ordered list inside.
- The last item has aria-current="page".
- Separators are icons hidden from assistive tech, so screen readers only read the levels.
- The collapse button is named "Show N more levels" and expands the trail in place.
- Truncated labels keep the full text in the title attribute and in the accessible name.
Tokens used: --border-width-1, --color-surface-hover, --color-text-default, --color-text-muted, --focus-ring-color, --focus-ring-width, --font-family-sans, --font-line-height-snug, --font-size-200, --font-tracking-wide, --font-weight-medium, --motion-duration-instant, --motion-easing-standard, --radius-inset, --size-6, --size-60, --space-1

### Pagination

Moves through a long list split into pages, like the token table or the review history. The first and last pages stay visible; the rest collapse into ellipses.

Import: `import { Pagination, getPageItems } from '@bridge-ds/react'`
Props:
- pageCount: number. Total number of pages.
- page?: number. Current page, 1-based (controlled).
- defaultPage?: number = 1. First page shown when uncontrolled. Default 1.
- onPageChange?: (page: number) => void. Called with the requested page, 1-based.
- siblingCount?: number = 1. Pages shown on each side of the current one before an ellipsis. Default 1.
- variant?: 'full' | 'compact' = 'full'. `full` lists page numbers; `compact` shows "Page 3 of 12" between previous and next (narrow spaces). Default `full`.
- size?: 'sm' | 'md' = 'md'. Button height from the density tokens. Default `md`.
When to use:
- Long, stable lists where users need to know where they are and jump back: token tables, audit logs.
- Below the list it controls, aligned with the list's start or end edge.
- compact in narrow spaces (side panels, mobile) and in card footers.
When not to use:
- Feeds the user skims once: load more or infinite scroll fit better.
- Fewer than about 25 items: show them all.
- Steps of a task: use a stepper.
Do:
- Keep the page in the URL. Reloading or sharing the link should land on the same page. Control page and onPageChange from your router.
Don't:
- Hiding previous and next at the ends. Disabled previous on page 1 keeps the layout still and tells the user they are at the start.
Accessibility:
- A nav landmark named "Pagination" (override with aria-label when a page has several).
- The current page button has aria-current="page" and an ink fill, not color alone.
- Page buttons are named "Page N"; previous and next are named "Previous page" and "Next page" and are disabled at the ends.
- Ellipses are icons hidden from assistive tech.
- In compact, the "Page 3 of 12" text is a polite live region, so page changes are announced.
Tokens used: --border-width-default, --button-ghost-bg-active, --button-ghost-bg-hover, --color-control-checked-bg, --color-control-checked-fg, --color-text-default, --color-text-muted, --control-height-md, --control-height-sm, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-size-200, --font-weight-medium, --motion-duration-instant, --motion-easing-standard, --radius-control, --space-1, --space-2

### SidebarNav

The app's side navigation: sections of links to the main areas of Bridge, with the current page marked by a signal mark and an ink label.

Import: `import { SidebarNav } from '@bridge-ds/react'`
Props:
- children?: ReactNode
When to use:
- Primary navigation of an app with more than five areas, in a left rail that stays visible.
- Group items into titled sections by task (Library, Governance, Workspace).
- collapsible on sections users rarely need, so the rail stays short.
- count for items that need attention, like reviews waiting for you.
When not to use:
- Switching views inside one page: use Tabs.
- Fewer than five destinations: a top bar is enough.
- Actions (Create token, Sync from Figma): put them in the page header as Buttons, not in the navigation.
Do:
- Mark exactly one current page. active on the item for the current URL, including when the user is on a child page of it.
Don't:
- Icons on some items only. A ragged column of icons breaks the scan line. Use icons on every item of a section or none.
Accessibility:
- A nav landmark: give it an aria-label when the page has other navigation (Breadcrumb, Pagination).
- Each section is a list labelled by its title.
- The current item has aria-current="page". It also gets a 2px signal mark and ink text, so color is not the only cue.
- Collapsible section titles are buttons with aria-expanded (Base UI Collapsible); Enter and Space toggle them.
- Items are full-width targets at the menu item height (36px comfortable, 30px compact).
Tokens used: --border-width-indicator, --color-bg-muted, --color-text-default, --color-text-muted, --focus-ring-color, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-100, --font-size-200, --font-tracking-wide, --font-weight-medium, --menu-item-height, --motion-duration-fast, --motion-duration-instant, --motion-duration-moderate, --motion-easing-enter, --motion-easing-standard, --nav-indicator, --nav-item-bg-active, --nav-item-bg-hover, --radius-control, --radius-pill, --size-7, --space-0-5, --space-1, --space-1-5, --space-2, --space-3, --space-5

### Tabs

Switches between views of the same object without leaving the page: the tokens, components and reviews of one library. The selected tab is marked with the signal underline.

Import: `import { Tabs } from '@bridge-ds/react'`
Accepts all native <div> attributes except defaultValue, onChange.
Props:
- variant?: 'underline' | 'contained' = 'underline'. `underline` for page-level sections (signal underline); `contained` for a compact segmented switch. Default `underline`.
- size?: 'sm' | 'md' = 'md'. Tab height follows density tokens. Default `md`.
- defaultValue?: string | number | null. Value of the tab selected on first render (uncontrolled).
- value?: string | number | null. Value of the selected tab (controlled).
- onValueChange?: (value: TabsValue) => void. Called with the new value when the user selects a tab.
- orientation?: 'horizontal' | 'vertical' = 'horizontal'
- children?: ReactNode
When to use:
- Two to seven peer views of the same context, where users switch back and forth.
- underline for page and card sections; contained for a compact switch inside a toolbar (Light or Dark preview).
- count to show how many items wait in a view, like open reviews.
- Vertical orientation for settings pages with many sections next to their content.
When not to use:
- Steps of a sequence the user must complete in order: use a stepper or a form with sections.
- Navigation between pages with their own URL: use SidebarNav or links styled as tabs, not Tabs (which do not change the URL).
- Changing a setting or filter value: use RadioGroup or an attached ButtonGroup.
Do:
- Short, parallel labels. One or two nouns per tab, same grammatical form. The user scans them as a set.
Don't:
- Tabs that are really actions. "Export" or "Delete" in a tab list hides an action behind a view switch. Use a Button.
Accessibility:
- Base UI Tabs: role tablist, tab and tabpanel, aria-selected, aria-controls and aria-labelledby are wired for you.
- One tab stop for the whole list. Arrow keys move between tabs (Up and Down when vertical), Home and End jump, focus wraps by default.
- By default arrows move focus and Enter or Space selects; set activateOnFocus when panels are cheap to render.
- Give the TabsList an aria-label (or labelledby) so the tablist has a name.
- The selected tab has a visible signal underline plus stronger text: never color alone.
- Disabled tabs are announced as disabled and cannot be selected.
Tokens used: --border-width-default, --border-width-indicator, --color-bg-subtle, --color-border-default, --color-surface-default, --color-text-default, --color-text-disabled, --control-height-md, --control-height-sm, --elevation-raised, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-line-height-tight, --font-size-100, --font-size-200, --font-weight-medium, --layout-gap, --motion-duration-instant, --motion-duration-moderate, --motion-easing-standard, --radius-container, --radius-control, --radius-pill, --space-0-5, --space-1, --space-1-5, --space-2, --space-3, --space-5, --tabs-indicator, --tabs-text, --tabs-text-active

### Accordion

Stacked sections that expand in place. Lets users scan headings and open only what they need.

Import: `import { Accordion } from '@bridge-ds/react'`
Props:
- children: ReactNode
- multiple?: boolean = false. Allow several panels open at once. Default `false`.
- defaultValue?: string[]. Values of the items open by default (uncontrolled).
- value?: string[]. Values of the open items (controlled).
- onValueChange?: (value: string[]) => void
- headingLevel?: 2 | 3 | 4 | 5 | 6 = 3. Heading level of every item trigger, to fit the page outline. Default `3`.
- appearance?: 'contained' | 'flush' = 'contained'. `contained` draws an outer border (inside a page); `flush` only separates items (inside a card). Default `contained`.
- disabled?: boolean
- className?: string
When to use:
- Long reference content where users look for one section: token groups, FAQ, advanced settings.
- multiple when users compare sections; single (default) when sections are alternatives.
- flush inside a card or a side panel, contained on a page.
When not to use:
- Content everyone needs: do not hide it behind a click.
- Switching between views of the same data: use Tabs.
- Steps in a sequence: use a stepped layout with headings.
Do:
- Headings that work as a table of contents. Users scan the closed state. Name each section by its content, and show a count in meta when it helps.
Don't:
- Nesting accordions. A collapsible inside a collapsible hides content twice. Flatten the structure or split the page.
Accessibility:
- Base UI Accordion: each trigger is a button inside a heading (headingLevel, default h3) with aria-expanded and aria-controls.
- Panels are regions labelled by their trigger. Enter and Space toggle; Tab moves between triggers.
- Disabled items stay visible but cannot open.
- The height animation uses motion tokens and collapses to 0ms with reduced motion.
Tokens used: --accordion-bg-hover, --accordion-border, --accordion-icon, --accordion-text, --border-width-default, --color-border-strong, --color-text-default, --color-text-disabled, --color-text-muted, --control-height-lg, --focus-ring-color, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-100, --font-size-200, --font-weight-medium, --font-weight-regular, --layout-inset, --motion-duration-instant, --motion-duration-moderate, --motion-easing-standard, --radius-container, --space-2, --space-3

### Avatar

A person or a team, shown as a photo with an initials fallback. AvatarGroup stacks several and collapses the rest into a +N counter.

Import: `import { Avatar, initials } from '@bridge-ds/react'`
Accepts all native <span> attributes.
Props:
- name: string. Full name. Used for the initials fallback and as the accessible name.
- src?: string. Image URL. While it loads or if it fails, the initials show instead.
- size?: 'xs' | 'sm' | 'md' | 'lg' = 'md'. xs 20, sm 24, md 32, lg 40. Default `md`.
- decorative?: boolean = false. Hide from assistive tech when the name is already written next to the avatar.
When to use:
- Owners, reviewers and authors next to tokens, components and pull requests.
- AvatarGroup for the reviewers of a change; set max so the row never wraps.
When not to use:
- As the only way to identify someone in a table: write the name too.
- For product or brand logos: use an image with its own alt text.
Do:
- Name next to the avatar in lists. Faces are hard to tell apart at 24px. Mark the avatar decorative when the name is right beside it.
Don't:
- Ten avatars in a row. Past four or five faces nobody counts. Let the +N counter carry the rest.
Accessibility:
- role img with the full name as its label; initials are hidden from screen readers so they do not spell letters.
- decorative removes it from the accessibility tree when the name is already written next to it.
- The image has an empty alt: the name is on the container. Broken images fall back to initials (Base UI Avatar).
- AvatarGroup is a named group; the +N counter is announced as "3 more: names".
Tokens used: --avatar-bg, --avatar-ring, --avatar-text, --border-width-2, --color-bg-subtle, --color-text-muted, --font-family-sans, --font-line-height-none, --font-size-100, --font-size-200, --font-weight-semibold, --radius-pill, --size-10, --size-5, --size-6, --size-8

### Badge

A short, non-interactive status label like Draft, Published or Failed. The words carry the meaning; the tone reinforces it.

Import: `import { Badge } from '@bridge-ds/react'`
Accepts all native <span> attributes.
Props:
- tone?: 'neutral' | 'success' | 'warning' | 'danger' | 'info' = 'neutral'. Meaning of the status. The text must say it too: tone only reinforces.
- appearance?: 'subtle' | 'solid' = 'subtle'. `subtle` (tinted, default) for dense lists and tables; `solid` for the one status that must win a glance.
- size?: 'sm' | 'md' = 'md'
- dot?: boolean = false. Leading dot in the tone color. Ignored when an icon is set.
- icon?: LucideIcon. Leading Lucide icon, for example CircleCheck for success.
When to use:
- The status of one item in a table row, a list or a card header.
- subtle (default) in lists and tables where many badges sit together; solid for the single status that must win a glance (Breaking, Build failed).
- Tone by meaning: success done or healthy, warning needs attention, danger failed or blocked, info new or changed, neutral no judgement.
When not to use:
- Anything clickable or removable: use Tag.
- A sentence or an explanation: use an inline Alert.
- Counts on icons: use the number in text next to the label.
Do:
- The text says the status, the tone backs it up. Someone who cannot tell green from red still reads Published and Failed.
- Subtle in rows, solid for the exception. A table full of solid badges shouts. Keep solid for the one thing that must stand out.
Don't:
- Color as the only signal. A dot without words fails WCAG 1.4.1 and means nothing to a screen reader.
- A tone that contradicts the words. Danger for New borrows urgency the user did not ask for.
Accessibility:
- Static text read in reading order. No role, not focusable.
- Subtle tones use feedback text on feedback bg (4.5:1); solid tones use text.on-solid on feedback solid (4.5:1). Checked by the token build in every brand and theme.
- The dot and icon are decorative; the text carries the status.
- If a status changes while the user watches (Running to Failed), announce it with a live region; the badge itself is silent.
Tokens used: --border-width-default, --color-bg-subtle, --color-border-default, --color-feedback-danger-bg, --color-feedback-danger-border, --color-feedback-danger-icon, --color-feedback-danger-solid, --color-feedback-danger-text, --color-feedback-info-bg, --color-feedback-info-border, --color-feedback-info-icon, --color-feedback-info-solid, --color-feedback-info-text, --color-feedback-success-bg, --color-feedback-success-border, --color-feedback-success-icon, --color-feedback-success-solid, --color-feedback-success-text, --color-feedback-warning-bg, --color-feedback-warning-border, --color-feedback-warning-icon, --color-feedback-warning-solid, --color-feedback-warning-text, --color-surface-inverse, --color-text-default, --color-text-inverse, --color-text-muted, --color-text-on-solid, --font-family-sans, --font-line-height-tight, --font-size-100, --font-tracking-wide, --font-weight-medium, --radius-pill, --size-5, --size-6, --space-1, --space-1-5, --space-2

### Card

A flat plate with a hairline edge that groups related content: a release, a component summary, a settings section. It does not cast a shadow unless the whole card is one link.

Import: `import { Card } from '@bridge-ds/react'`
Accepts all native <div> attributes.
Props:
- interactive?: boolean = false. The whole card is one link or action: it lifts on hover, and the element marked `data-card-primary` (a link or button whose text names the destination, usually the title) covers the card and moves its focus ring to the card. Other buttons inside stay clickable on top.
- padding?: 'none' | 'compact' | 'default' = 'default'. Inner spacing of header, body and footer. `none` for full-bleed tables and media. Default `default`.
When to use:
- A self-contained unit the user scans or compares: a release, a component, a token group, an owner.
- interactive when the whole card goes to one place (a component page): mark its title link with data-card-primary.
- padding none for a full-bleed Table or media inside the card; compact in dense dashboards.
When not to use:
- As the page structure: rows of identical cards are a lazy layout. Use headings, lists and tables first.
- Inside another card: nested plates blur hierarchy. Use a Divider or a heading.
- Floating content (menus, popovers): those are overlays with elevation, not cards.
Do:
- One primary action in the footer. The footer is for the decision; secondary actions step down to ghost.
Don't:
- A card around a single sentence. A box with one line adds noise. Let the text sit on the page.
Accessibility:
- A card is a plain div. Pass aria-labelledby pointing at its title when it should be a navigable group.
- Interactive cards keep a real link (data-card-primary) whose text names the destination; its click area is stretched over the card and its focus ring moves to the card edge.
- Other buttons inside an interactive card stay on top and remain separately focusable.
- The hover lift is skipped when the user prefers reduced motion.
Tokens used: --border-width-1, --border-width-default, --card-bg, --card-border, --card-radius, --card-shadow, --color-bg-subtle, --color-border-hover, --color-text-default, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-normal, --font-line-height-snug, --font-size-200, --font-size-400, --font-tracking-snug, --font-weight-semibold, --layout-inset, --motion-duration-fast, --motion-easing-standard, --space-2, --space-3, --space-4

### Divider

A hairline that separates groups of content, horizontally or vertically, with an optional short label.

Import: `import { Divider } from '@bridge-ds/react'`
Accepts all native <div> attributes.
Props:
- orientation?: 'horizontal' | 'vertical' = 'horizontal'
- label?: ReactNode. Short text centered on a horizontal line, like "Or" or "Older changes".
- spacing?: 'none' | 'sm' | 'md' = 'md'. Space around the line: `none`, `sm` (8px) or `md` (16px). Default `md`.
When to use:
- Between groups that belong to the same surface but differ in kind: settings sections, toolbar groups.
- Labelled, to name what follows ("Older changes") or to offer an alternative ("Or").
When not to use:
- Between every list item: use spacing, or row borders in a Table.
- To separate sections that have headings: the heading and space already do it.
Do:
- Vertical dividers between toolbar groups. They group related controls without adding boxes.
Don't:
- Stacked dividers and borders. A divider next to a card edge doubles the line. Pick one.
Accessibility:
- Base UI Separator: role separator with aria-orientation, announced as a boundary.
- The labelled variant renders two separators around plain text, so the label is read once in order.
- The line uses border.default (decorative); never rely on it to convey meaning.
Tokens used: --border-width-default, --divider-line, --divider-text, --font-family-sans, --font-size-100, --font-tracking-wide, --font-weight-medium, --size-4, --space-2, --space-3, --space-4

### Kbd

Shows a keyboard key or a key combination, as nested kbd elements in the mono face.

Import: `import { Kbd } from '@bridge-ds/react'`
Props:
- keys: string | string[]. One key ("Esc") or a combination (["Cmd", "K"]) pressed together.
- size?: 'sm' | 'md' = 'md'
When to use:
- Teaching a shortcut in a tooltip, a menu item, an empty state or help text.
- Inside an Input suffix to hint at the search shortcut.
When not to use:
- Code or token names: use code.
- Buttons: a Kbd is not clickable.
Do:
- Write the platform's key names. Cmd on macOS, Ctrl elsewhere. Abbreviations get a title with the full name.
Don't:
- Long sequences. More than three keys is a recipe, not a shortcut. Explain it in text.
Accessibility:
- Outer kbd wraps one kbd per key, the HTML pattern for keys pressed together; the plus signs are aria-hidden.
- Abbreviated keys (Cmd, Esc, arrows) carry a title with the full name.
- Text color is text.muted on surface (4.5:1).
Tokens used: --border-width-default, --color-text-muted, --font-family-mono, --font-family-sans, --font-line-height-none, --font-size-100, --font-weight-medium, --kbd-bg, --kbd-border, --kbd-border-bottom, --kbd-radius, --kbd-text, --size-4, --size-5, --space-0-5, --space-1

### Table

A semantic data table for comparing records: sortable columns, selectable rows, density-aware rows, and a focusable scroll region on narrow screens.

Import: `import { Table, useTableSort } from '@bridge-ds/react'`
Accepts all native <HTMLTableElement> attributes.
Props:
- caption?: ReactNode. Visible caption above the table. Also names the scroll region.
- stickyHeader?: boolean = false. Accessible name when there is no visible caption, for example "Tokens changed in this pull request". */ 'aria-label'?: string; /** Keep the header row visible while the table scrolls inside its container.
- maxHeight?: string. Max height of the scroll container (CSS length from a token, for example var(--size-120)). Enables vertical scroll.
- gridLines?: boolean = false. Hairlines between columns as well as rows. Default `false`.
When to use:
- Records with the same attributes that users compare, sort or act on: tokens, components, pull requests.
- Sortable headers (sortDirection + onSort, or useTableSort) on columns users reorder by.
- Selectable rows: a Checkbox in the first cell, selected on the row, bulk actions above the table.
- Compact density for expert, data-heavy screens.
When not to use:
- Layout: never use a table to position content.
- Fewer than three attributes per item: use a list.
- Editing many cells inline like a spreadsheet: that needs a grid pattern, not in the system yet.
Do:
- Right-align numbers. numeric aligns digits on the right with tabular figures, so values compare at a glance.
- Say what to do when it is empty. TableEmpty keeps the header in place and explains how rows will appear.
Don't:
- Color as the only row state. A tinted row alone does not say selected. Pair it with a checked Checkbox.
Accessibility:
- Native table, thead, th scope col and td: screen readers announce headers with each cell.
- Sortable headers contain a button; the th gets aria-sort ascending or descending on the sorted column only.
- Selected rows set aria-selected and use the selected surface plus a checked Checkbox (never color alone).
- The scroll container is a labelled region with tabIndex 0, so keyboard users can scroll wide tables (named by the caption or aria-label).
- Row height and cell padding follow density: 48/36px rows, both above the 24px target minimum for the controls inside.
Tokens used: --border-width-default, --card-border, --color-border-default, --color-signal-default, --color-surface-default, --color-text-default, --color-text-muted, --control-height-md, --focus-ring-color, --focus-ring-offset, --focus-ring-width, --font-family-sans, --font-line-height-snug, --font-size-100, --font-size-200, --font-size-300, --font-tracking-wide, --font-weight-medium, --font-weight-semibold, --motion-duration-instant, --motion-easing-standard, --radius-container, --radius-inset, --size-6, --space-1, --space-3, --space-8, --table-border, --table-cell-padding-x, --table-header-bg, --table-header-text, --table-row-bg-hover, --table-row-bg-selected, --table-row-height

### Tag

A compact label for a value. Static to describe, toggleable as a filter chip, or removable to show selected values.

Import: `import { Tag } from '@bridge-ds/react'`
Accepts all native <span> attributes except children.
Props:
- children: ReactNode. The tag text: a filter value, a token group, a component name.
- tone?: 'neutral' | 'signal' = 'neutral'. `signal` marks a tag that matches the current search or filter. Default `neutral`.
- icon?: LucideIcon. Leading Lucide icon.
- selected?: boolean. Makes the tag a toggle (filter chip). Controlled pressed state, announced with aria-pressed.
- defaultSelected?: boolean. Uncontrolled initial selected state for a toggle tag.
- onSelectedChange?: (selected: boolean) => void. Called when a toggle tag is pressed. Passing it makes the tag interactive.
- onRemove?: () => void. Adds a remove button. Called when it is pressed.
- removeLabel?: string. Accessible name of the remove button, without the verb. Defaults to the tag text when it is a string.
- disabled?: boolean = false
- size?: 'sm' | 'md' = 'md'
When to use:
- Filter chips above a table or a list: toggle tags in a labelled group.
- Selected values in a multi-select Combobox or a filter bar: removable tags.
- Static metadata: token groups, platforms, component categories.
When not to use:
- Status (Published, Failed): use Badge.
- Actions: use Button. A tag describes a value; it does not do something.
- Choosing one option out of several: use RadioGroup or Tabs.
Do:
- Put filter chips in a named group. role group with aria-label ("Filter by theme") tells screen reader users what the chips control.
Don't:
- Sentences in tags. Tags truncate. Keep them to a value of one to three words.
Accessibility:
- Toggle tags are Base UI Toggle buttons with aria-pressed; selected also shows a check icon, so selection is not color alone.
- The remove button is a separate button named "Remove <label>" (removeLabel when the text is not a plain string).
- Both buttons have the focus ring and are reachable with Tab; Enter and Space activate them.
- Static tags are plain text with no role.
Tokens used: --border-width-default, --color-action-disabled-bg, --color-action-disabled-text, --color-surface-pressed, --color-text-default, --focus-ring-color, --focus-ring-width, --font-family-sans, --font-line-height-tight, --font-size-100, --font-tracking-wide, --font-weight-medium, --motion-duration-instant, --motion-easing-standard, --size-5, --size-6, --space-0-5, --space-1, --space-2, --tag-bg, --tag-bg-hover, --tag-bg-selected, --tag-border, --tag-radius, --tag-signal-bg, --tag-signal-text, --tag-text, --tag-text-selected

### Container

Centers page content at a max width from the size scale with token side padding. Phones always get a 16px gutter.

Import: `import { Container } from '@bridge-ds/react'`
Accepts all native <div> attributes.
Props:
- size?: 'sm' | 'md' | 'lg' | 'xl' | 'full' = 'xl'. Max width: sm 640 (forms, reading), md 720, lg 960 (settings), xl 1200 (dashboards, tables), full. Default `xl`.
- padding?: 'none' | 'sm' | 'md' | 'lg' = 'md'. Horizontal padding from the space scale: none, sm (16), md (24), lg (32). Default `md`.
- render?: ReactElement. Render another element, for example `<main />`.
When to use:
- The outer wrapper of a page or a page section.
- sm for forms and reading (keeps lines under about 75 characters), lg for settings, xl for dashboards and tables.
- render={<main />} on the page's main region.
When not to use:
- Inside cards or dialogs: they already set their width and padding.
- Full-bleed backgrounds: put the background on an outer element and the Container inside it.
Do:
- Match the width to the content. Forms in an xl container stretch inputs to unreadable lengths. Pick sm.
Don't:
- Nested containers. A container inside a container doubles the padding and adds nothing.
Accessibility:
- A plain div by default; render as <main /> for the main region so the landmark exists.
- Content reflows at 320px wide without horizontal scroll (WCAG 1.4.10).
Tokens used: --size-160, --size-180, --size-240, --size-300, --space-4, --space-6, --space-8

### Grid

Two-dimensional layout with token gaps: a fixed number of equal columns, or as many columns as fit for card and tile grids.

Import: `import { Grid } from '@bridge-ds/react'`
Accepts all native <div> attributes.
Props:
- columns?: number = 1. Fixed number of equal columns (1 to 12). Ignored when minItemWidth is set. Default 1.
- minItemWidth?: '60' | '72' | '80' | '96' | '120'. Responsive columns: as many as fit, each at least this size token wide (`80` = 320px). Use this instead of breakpoints for card and tile grids.
- gap?: SpaceToken. Space between cells, a key of the space scale. Default: the density token `layout.gap`.
- render?: ReactElement. Render another element, for example `<ul />`.
When to use:
- Card and tile grids: minItemWidth makes them responsive without breakpoints.
- Dashboards and settings layouts with a fixed column count.
- render={<ul />} when the cells are a list of items.
When not to use:
- Tabular data with headers: use Table.
- A single row or column: use Inline or Stack.
Do:
- Prefer minItemWidth for tiles. Tiles reflow from four columns to one as space shrinks, with no media queries.
Don't:
- Many fixed columns on small screens. Twelve fixed columns at phone width make unreadable slivers. Use minItemWidth or fewer columns.
Accessibility:
- A plain div by default with no role; it is not a data grid. Use Table for data.
- Cells flow in DOM order, so reading and tab order follow the visual order row by row.
Tokens used: --layout-gap

### Inline

Lays out children in a row with a token gap and wraps them when space runs out: badges, button rows, metadata.

Import: `import { Inline } from '@bridge-ds/react'`
Accepts all native <div> attributes.
Props:
- gap?: SpaceToken = '2'. Space between children, a key of the space scale. Default `2` (8px).
- align?: FlexAlign = 'center'. Cross-axis alignment. Default `center`.
- justify?: FlexJustify = 'start'. Main-axis distribution. Default `start`.
- wrap?: boolean = true. Wrap onto new lines when space runs out. Default true.
- render?: ReactElement. Render another element, for example `<ul />`.
When to use:
- Rows of related small items: badges, tags, buttons, meta text.
- justify="between" for page and card headers: title at the start, actions at the end.
- wrap={false} only when the row must stay on one line and can scroll or truncate.
When not to use:
- A vertical list: use Stack.
- Columns that must line up across rows: use Grid or Table.
Do:
- Let rows wrap. Wrapping keeps every item reachable at narrow widths and at 200% zoom.
Don't:
- Fixed widths inside an Inline. Fixed widths fight the wrap and overflow on phones. Let content size the items.
Accessibility:
- A plain div by default; use render for a list (<ul />) when the items are a list.
- Wrapping never reorders items, so the visual and tab order match.
Tokens used: --space-2

### Stack

Stacks children in a column with a gap from the space scale. The default gap follows density (16px comfortable, 12px compact).

Import: `import { Stack, gapStyle } from '@bridge-ds/react'`
Accepts all native <div> attributes.
Props:
- gap?: '0' | '0-5' | '1' | '1-5' | '2' | '3' | '4' | '5' | '6' | '8' | '10' | '12' | '16'. Space between children, a key of the space scale. Default: the density token `layout.gap` (16 or 12px).
- align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline' = 'stretch'. Cross-axis alignment. Default `stretch`.
- justify?: 'start' | 'center' | 'end' | 'between' = 'start'. Main-axis distribution. Default `start`.
- render?: ReactElement. Render another element, for example `<section />` or `<ul />`.
When to use:
- Vertical rhythm inside cards, dialogs, forms and page sections.
- gap from the space scale: tight (1 to 2) inside a group, generous (6 to 8) between groups.
- render to output a semantic element: <section />, <ul />, <form />.
When not to use:
- Rows of items: use Inline.
- Two-dimensional layouts with columns: use Grid.
- Spacing one element only: give that element a margin class in your app, not a wrapper.
Do:
- Nest stacks to show grouping. A tight stack per group inside a looser stack: the space itself says what belongs together.
Don't:
- One gap for everything. Equal spacing everywhere flattens hierarchy. Vary the gap to separate groups.
Accessibility:
- A plain div by default with no role: it changes layout, not semantics.
- Use render to give the stack the right element (a list of items should be <ul />).
- Visual order equals DOM order, so reading and tab order stay consistent.
Tokens used: --layout-gap

## Optional

- [AGENTS.md](https://github.com/peterbono/ai-design-system-demo/blob/main/AGENTS.md): full agent instructions
- [GOVERNANCE.md](https://github.com/peterbono/ai-design-system-demo/blob/main/docs/GOVERNANCE.md): risk tiers and review gates
- [Token sources](https://github.com/peterbono/ai-design-system-demo/tree/main/packages/tokens/src): DTCG JSON

MCP server

Tokens resolved in every brand, theme and density, the contrast gate, components with their props and usage docs, and the agent rules. Claude builds UI with real tokens and proposes token changes as pull requests your design and tech owners review.

Not published on npm yet. @bridge-ds/mcp and @bridge-ds/cli run from a local build of this repository, as shown below. The npx form works once they are published.

1. Build once

Terminal
git clone https://github.com/peterbono/ai-design-system-demo
cd ai-design-system-demo
npm install
npm run mcp:build

The server is then at packages/mcp/dist/index.js and the CLI at packages/cli/dist/index.js.

2. Claude Code

Add the server for your user, pointing it at your design system folder:

Terminal
claude mcp add bridge -- node /abs/path/ai-design-system-demo/packages/mcp/dist/index.js --root /abs/path/to/your-ds

Or share it with your team through git: a project .mcp.json at the repository root. This repository ships one, and Claude Code asks you to approve project servers the first time.

.mcp.json
{
  "mcpServers": {
    "bridge": {
      "command": "node",
      "args": [
        "packages/mcp/dist/index.js",
        "--root",
        "."
      ]
    }
  }
}

3. Claude Desktop

In Settings, Developer, Edit config, add this to claude_desktop_config.json, then restart Claude Desktop:

claude_desktop_config.json
{
  "mcpServers": {
    "bridge": {
      "command": "node",
      "args": [
        "/abs/path/ai-design-system-demo/packages/mcp/dist/index.js",
        "--root",
        "/abs/path/to/your-ds"
      ]
    }
  }
}
Work on a GitHub repository instead of a local folder

Pass --repo owner/name. Without a token Bridge is read-only. Add GITHUB_TOKEN only if you want Claude to open pull requests: a fine-grained token limited to that one repository, with contents and pull requests read and write.

{
  "mcpServers": {
    "bridge": {
      "command": "node",
      "args": [
        "/abs/path/packages/mcp/dist/index.js",
        "--repo",
        "owner/name"
      ],
      "env": {
        "GITHUB_TOKEN": "github_pat_..."
      }
    }
  }
}

Other MCP clients work the same way over stdio: node packages/mcp/dist/index.js --root <dir> or --repo owner/name. Options can also come from BRIDGE_ROOT, BRIDGE_REPO, BRIDGE_REF and GITHUB_TOKEN.

What Claude can do: 21 tools

ToolUse it to
bridge_get_config See where the design system is, which modes exist, and what Bridge may write
bridge_list_tokens Browse tokens by tier (primitive, brand, semantic, density, component), type, prefix, mode
bridge_get_token Get a token's value in every mode, its alias chain, what aliases it and which components use it
bridge_search_tokens Find the right token by intent ("danger text"), CSS variable or value (#c2410c, 16px)
bridge_check_contrast Run the WCAG contrast gate; preview the effect of edits before proposing them
bridge_validate_tokens Check DTCG shapes, aliases (broken, circular), type mismatches, light and dark parity
bridge_list_components Pick an existing component before writing new markup
bridge_get_component Read props (types, defaults, allowed values), when to use, dos and donts, accessibility, anatomy
bridge_scaffold_component Generate a component folder that follows the repo checklist
bridge_get_design_language Read the relevant section of the design language
bridge_get_guidelines Read the agent rules (always, ask before, never) and the risk tiers
bridge_propose_token_change Turn token edits into a branch (local) or a draft PR (GitHub), with a designer-friendly body
bridge_list_reviews List open PRs and local proposal branches
bridge_get_review Review a token PR: diff table, contrast before and after, downstream impact, tier, CI, approvals
bridge_review_pr Comment, approve or request changes on a PR, as you
bridge_visual_impact See what a change does on screen: before/after/diff of the impacted stories and app screens
bridge_get_history See what changed in the tokens: versions, merged PRs and commits, who, when, why, tier, breaking
bridge_get_token_history Every change of one token: value before and after per mode, the PR and why, renames followed
bridge_plan_revert Dry run of undoing a change (or restoring one token) against today's base branch, conflicts shown
bridge_propose_revert Turn that plan into a branch (local) or a draft PR labelled revert (GitHub), never main
bridge_audit Measure how a product codebase adopts the design system: token and component adoption, hardcoded values with the closest token, foreign UI components with the DS component to use, top files and fixes

Prompts

Slash commands in Claude Code, for example /mcp__bridge__audit-ui-for-tokens.

  • review-token-pr: review a token PR like the design system owner, then post only after you confirm.
  • add-component: check for an existing component, plan the API and tokens, then scaffold.
  • audit-ui-for-tokens: paste CSS or TSX; Bridge flags hardcoded colors, lengths, durations, shadows and z-index and suggests the token for each, matched to the property (a background gets a surface token).
  • migrate-file-to-ds: give a product file; Bridge audits it (values and components with line numbers), then Claude rewrites it with DS components and tokens, explains each change and re-runs the audit.

Things to ask

  • Which token should this card background use in dark mode?
  • Darken muted text in the light theme and show me the contrast impact first.
  • Review PR 42 for the design owner.
  • Audit this component's CSS for hardcoded values.

bridge CLI

The same checks and proposals from a terminal or CI. Build it once (see the MCP server steps), then run it in your design system repository, starting with init:

Terminal
node /abs/path/ai-design-system-demo/packages/cli/dist/index.js init
bridge init
Detects your token folder, tiers and modes, writes bridge.config.json and .mcp.json, prints the Claude Desktop snippet.
bridge check
Validates tokens and runs the contrast gate. Use it in CI: exit 0 ok, 1 failures, 2 setup error.
bridge tokens <query>
Searches tokens by words, CSS variable or value.
bridge diff <base>
Designer-readable token diff against a git ref, with alias impact, contrast and the risk tier.
bridge propose --title "..." --set path=value
Turns token edits into a commit on a new local bridge/* branch. Blocked when a check fails.
bridge history [token]
Token history: versions and merged changes, or every change of one token, with who, when and why.
bridge revert <pr|commit> --dry-run
Plans the undo of a merged token change against today: conflicts with later changes shown. Without --dry-run, a new local branch.
bridge impact --plan
Visual impact: which stories and app screens a change touches. Without --plan, screenshots both refs and pixel-diffs them (needs Chrome).
bridge audit <path>
Design system adoption in product code: token and component adoption, hardcoded values with the closest token, top files. --fix for exact CSS matches.
bridge mcp
Starts the MCP server on stdio, same as node packages/mcp/dist/index.js.

Safety model

  • Read-only by default. Without GITHUB_TOKEN nothing remote is written. Locally, read tools never write.

  • Never `main`. Proposals go to new branches named bridge/<title>. The protected list (main, master, the configured base branch) is checked in the GitHub client and the local git writer, whatever the caller asks. Existing branches are never overwritten; a new suffix is used instead.

  • Pull requests, not pushes. Remote proposals open a draft PR labelled source: mcp and tier: N. Local proposals commit to a local branch through git plumbing: your checkout, index and working tree are untouched, and nothing is pushed.

  • Reverts are planned, not blind. A revert is computed against today's base branch; tokens that changed again since are shown as conflicts and kept unless you choose otherwise.

  • Gates before writes. A proposal that adds a validation error or makes a contrast pair fail is blocked and nothing is written.

  • Tiers. Any token added, changed, renamed or removed is Tier 3 (design and tech owner approval), as in docs/GOVERNANCE.md. Renames and removals are major; a description-only edit is Tier 1, patch. The Figma plugin, the docs site and the MCP server use the same classifier. Bridge computes the tier from the diff, writes the changeset, and warns in reviews when a PR is labelled lower than its real tier.

  • Humans merge. Bridge has no merge tool. bridge_review_pr posts a review under your account only when you ask.

  • Tokens. Use a fine-grained GitHub token limited to the one repo, with contents and pull requests read and write. Bridge sends it only to api.github.com.

Full guide: docs/AI.md. Source and license: GitHub, MIT.