# txbrendan/ui > Themeable React components that coding agents can use, theme, and check. Styled with Tailwind v4 and one set of theme tokens. Shared as a shadcn registry. The theme is checked for contrast in light and dark. This file holds every component page, every block page, every website component page, every illustration page, every template page, and the theme contract. The short index is at https://txbrendan.dev/llms.txt. Install in this order: 1. Add the registry to `components.json`. Do this first, once: ```bash npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json" ``` The command writes this entry. You can also add it by hand: ```json { "registries": { "@txbrendan": "https://txbrendan.dev/r/{name}.json" } } ``` Without this step, the shadcn CLI looks up `@txbrendan` in a public list that txbrendan/ui does not control. 2. Add the theme once per app. It replaces your color tokens with ones checked for contrast: ```bash npx shadcn@latest add @txbrendan/theme ``` 3. Add each component you need: ```bash npx shadcn@latest add @txbrendan/ ``` To use your own brand color, install a theme link in step 2, in place of our theme. Pick a color, corners, and buttons at https://txbrendan.dev/start, or write the ID yourself. A theme ID is a brand color as six lowercase hex digits, a dash, and round, soft, or sharp, like 0f766e-soft. Add -pill for pill buttons and fields, like 0f766e-soft-pill. The install looks like `npx shadcn@latest add https://txbrendan.dev/r/themes/0f766e-soft.json`. The server makes the theme from the ID, and turns down a color whose theme fails a contrast check. For pill buttons and fields in an app that already has a theme, run `npx shadcn@latest add @txbrendan/pill`. It makes buttons, toolbars, tabs, and fields fully round, and leaves every other token alone. A theme ID without -pill does not take the pill corners back out. To go back, delete the lines that name --button-radius, --tool-button-radius, --toolbar-radius, --tabs-radius, --input-radius, --select-trigger-radius from the global CSS. Blocks, website components, illustrations, and templates are ready-made sections, pages, and pictures built from these components. Add one the same way, by its name. The install also adds every component it uses, and the files are yours to edit. Props that set the look use these words and values: - `tone`: `neutral`, `brand`, `danger`, `success`, `warning` - `emphasis`: `solid`, `soft`, `outline`, `ghost` - `size`: `sm`, `md`, `lg` - `loading` and `disabled`: `true` or `false` Read a component's page before you use it. "Use it for" says when it fits. "Avoid it for" says when it does not, and what to use instead. The accessibility notes say what the component handles and what you still have to do. Each page also lists the props it takes and their defaults. The MCP server at https://txbrendan.dev/mcp serves the same component data over Streamable HTTP. It does not serve blocks, website components, illustrations, or templates yet. Its tools are `list_components`, `get_component`, and `get_theme_rules`. Add a theme ID to its URL, like https://txbrendan.dev/mcp?theme=0f766e-soft, and `get_component` gives that theme's install command in place of ours. ## Theme Every theme defines these color tokens in light and dark mode. Style with them, like `bg-muted` or `text-muted-foreground`, so both modes keep working. - Color tokens: `background`, `foreground`, `card`, `card-foreground`, `popover`, `popover-foreground`, `muted`, `muted-foreground`, `accent`, `accent-foreground`, `secondary`, `secondary-foreground`, `neutral`, `neutral-foreground`, `neutral-hover`, `primary`, `primary-foreground`, `primary-hover`, `primary-soft`, `primary-soft-foreground`, `destructive`, `destructive-foreground`, `destructive-hover`, `destructive-soft`, `destructive-soft-foreground`, `success`, `success-foreground`, `success-hover`, `success-soft`, `success-soft-foreground`, `warning`, `warning-foreground`, `warning-hover`, `warning-soft`, `warning-soft-foreground`, `border`, `input`, `ring`, `chart-1`, `chart-2`, `chart-3`, `chart-4`, `chart-5`, `sidebar`, `sidebar-foreground`, `sidebar-primary`, `sidebar-primary-foreground`, `sidebar-accent`, `sidebar-accent-foreground`, `sidebar-border`, `sidebar-ring` - The tokens each tone reads: - `neutral`: fill `neutral`, text `neutral-foreground`, hover `neutral-hover`, soft fill `secondary`, soft text `secondary-foreground` - `brand`: fill `primary`, text `primary-foreground`, hover `primary-hover`, soft fill `primary-soft`, soft text `primary-soft-foreground` - `danger`: fill `destructive`, text `destructive-foreground`, hover `destructive-hover`, soft fill `destructive-soft`, soft text `destructive-soft-foreground` - `success`: fill `success`, text `success-foreground`, hover `success-hover`, soft fill `success-soft`, soft text `success-soft-foreground` - `warning`: fill `warning`, text `warning-foreground`, hover `warning-hover`, soft fill `warning-soft`, soft text `warning-soft-foreground` - Densities: `compact`, `comfortable`, `spacious`. Each one sets `--control-h-sm`, `--control-h-md`, `--control-h-lg`, `--control-px-sm`, `--control-px-md`, `--control-px-lg`, `--control-gap`, `--space-inset`, `--space-stack`. - Motion feels: `calm`, `crisp`, `playful`, `off`. Each one sets `--motion-duration-fast`, `--motion-duration-base`, `--motion-duration-slow`, `--motion-duration-layout`, `--motion-duration-close`, `--motion-ease-out`, `--motion-ease-in-out`, `--motion-ease-spring`, `--motion-ease-close`, `--motion-press-scale`, `--motion-close-scale`, `--motion-distance`, `--motion-pulse-duration`, `--motion-crossfade-delay`. - Shadows: `--elevation-card`, `--elevation-float`, `--elevation-glow`, set in light and dark. Use `shadow-card` and `shadow-float`. - Type: `--type-meta`, `--type-meta-leading`, `--type-body`, `--type-body-leading`, `--type-prose-leading`, `--type-section`, `--type-section-leading`, `--type-display`, `--type-display-leading`, `--type-display-tracking`, `--type-headline`, `--type-hero`, `--type-headline-leading`, `--type-headline-tracking`, `--type-weight-regular`, `--type-weight-strong` on :root. Write them as `text-(length:--type-body)`, `leading-(--type-body-leading)`, and `font-(--type-weight-strong)`. - Headline font: website headlines add `font-headline`, which reads `--font-headline` in `@theme inline`. It is Geist Serif, loaded as `--font-geist-serif`, then the system serif. App components and blocks stay sans. ### Contrast pairs Every theme must pass all 81 pairs in both modes. - `foreground` on `background`: at least 4.5:1. Body text on the page. - `card-foreground` on `card`: at least 4.5:1. Text on cards. - `popover-foreground` on `popover`: at least 4.5:1. Text in menus and popovers. - `muted-foreground` on `background`: at least 4.5:1. Secondary text on the page. - `muted-foreground` on `muted`: at least 4.5:1. Secondary text on muted fills. - `muted-foreground` on `card`: at least 4.5:1. Secondary text on cards. - `muted-foreground` on `popover`: at least 4.5:1. Secondary text in popovers. - `foreground` on `muted`: at least 4.5:1. Text on muted fills. - `accent-foreground` on `accent`: at least 4.5:1. Text on hovered rows and items. - `secondary-foreground` on `secondary`: at least 4.5:1. Text on secondary fills. - `sidebar-foreground` on `sidebar`: at least 4.5:1. Sidebar text. - `sidebar-primary-foreground` on `sidebar-primary`: at least 4.5:1. Active sidebar item text. - `sidebar-accent-foreground` on `sidebar-accent`: at least 4.5:1. Hovered sidebar item text. - `input` on `background`: at least 3:1. Input borders must be visible on the page. - `input` on `card`: at least 3:1. Input borders must be visible on cards. - `input` on `popover`: at least 3:1. Input borders must be visible in dialogs and popovers. - `ring` on `background`: at least 3:1. Focus rings must be visible on the page. - `ring` on `card`: at least 3:1. Focus rings must be visible on cards. - `ring` on `popover`: at least 3:1. Focus rings must be visible in menus, dialogs, and popovers. - `chart-1` on `background`: at least 3:1. Chart marks must be visible. - `chart-2` on `background`: at least 3:1. Chart marks must be visible. - `chart-3` on `background`: at least 3:1. Chart marks must be visible. - `chart-4` on `background`: at least 3:1. Chart marks must be visible. - `chart-5` on `background`: at least 3:1. Chart marks must be visible. - `color-mix(in oklab, var(--card), var(--foreground) 14%)` on `background`: at least 1.25:1. Bar tracks must show on the page (track). - `color-mix(in oklab, var(--card), var(--foreground) 14%)` on `card`: at least 1.25:1. Bar tracks must show on cards (track). - `neutral-foreground` on `neutral`: at least 4.5:1. neutral solid: text on the fill. - `neutral-foreground` on `neutral-hover`: at least 4.5:1. neutral solid: text on the hover fill. - `neutral` on `background`: at least 3:1. neutral solid: the fill on the page (checked checkbox and switch). - `neutral` on `card`: at least 3:1. neutral solid: the fill on cards (checked checkbox and switch). - `secondary-foreground` on `secondary`: at least 4.5:1. neutral soft: text on the fill. - `secondary-foreground` on `color-mix(in oklab, var(--secondary), var(--neutral) 12%)`: at least 4.5:1. neutral soft: text on the hover fill (tone-soft-hover). - `secondary-foreground` on `background`: at least 4.5:1. neutral outline and ghost: text on the page. - `secondary-foreground` on `card`: at least 4.5:1. neutral outline and ghost: text on cards. - `secondary-foreground` on `muted`: at least 4.5:1. neutral text on muted fills, like a website card's values and comparison dots. - `secondary-foreground` on `popover`: at least 4.5:1. neutral outline and ghost: text in menus, dialogs, and popovers. - `color-mix(in oklab, var(--secondary-foreground), var(--background) 35%)` on `background`: at least 3:1. neutral outline: the border on the page (tone-line). - `primary-foreground` on `primary`: at least 4.5:1. brand solid: text on the fill. - `primary-foreground` on `primary-hover`: at least 4.5:1. brand solid: text on the hover fill. - `primary` on `background`: at least 3:1. brand solid: the fill on the page (checked checkbox and switch). - `primary` on `card`: at least 3:1. brand solid: the fill on cards (checked checkbox and switch). - `primary-soft-foreground` on `primary-soft`: at least 4.5:1. brand soft: text on the fill. - `primary-soft-foreground` on `color-mix(in oklab, var(--primary-soft), var(--primary) 12%)`: at least 4.5:1. brand soft: text on the hover fill (tone-soft-hover). - `primary-soft-foreground` on `background`: at least 4.5:1. brand outline and ghost: text on the page. - `primary-soft-foreground` on `card`: at least 4.5:1. brand outline and ghost: text on cards. - `primary-soft-foreground` on `muted`: at least 4.5:1. brand text on muted fills, like a website card's values and comparison dots. - `primary-soft-foreground` on `popover`: at least 4.5:1. brand outline and ghost: text in menus, dialogs, and popovers. - `color-mix(in oklab, var(--primary-soft-foreground), var(--background) 35%)` on `background`: at least 3:1. brand outline: the border on the page (tone-line). - `destructive-foreground` on `destructive`: at least 4.5:1. danger solid: text on the fill. - `destructive-foreground` on `destructive-hover`: at least 4.5:1. danger solid: text on the hover fill. - `destructive` on `background`: at least 3:1. danger solid: the fill on the page (checked checkbox and switch). - `destructive` on `card`: at least 3:1. danger solid: the fill on cards (checked checkbox and switch). - `destructive-soft-foreground` on `destructive-soft`: at least 4.5:1. danger soft: text on the fill. - `destructive-soft-foreground` on `color-mix(in oklab, var(--destructive-soft), var(--destructive) 12%)`: at least 4.5:1. danger soft: text on the hover fill (tone-soft-hover). - `destructive-soft-foreground` on `background`: at least 4.5:1. danger outline and ghost: text on the page. - `destructive-soft-foreground` on `card`: at least 4.5:1. danger outline and ghost: text on cards. - `destructive-soft-foreground` on `muted`: at least 4.5:1. danger text on muted fills, like a website card's values and comparison dots. - `destructive-soft-foreground` on `popover`: at least 4.5:1. danger outline and ghost: text in menus, dialogs, and popovers. - `color-mix(in oklab, var(--destructive-soft-foreground), var(--background) 35%)` on `background`: at least 3:1. danger outline: the border on the page (tone-line). - `success-foreground` on `success`: at least 4.5:1. success solid: text on the fill. - `success-foreground` on `success-hover`: at least 4.5:1. success solid: text on the hover fill. - `success` on `background`: at least 3:1. success solid: the fill on the page (checked checkbox and switch). - `success` on `card`: at least 3:1. success solid: the fill on cards (checked checkbox and switch). - `success-soft-foreground` on `success-soft`: at least 4.5:1. success soft: text on the fill. - `success-soft-foreground` on `color-mix(in oklab, var(--success-soft), var(--success) 12%)`: at least 4.5:1. success soft: text on the hover fill (tone-soft-hover). - `success-soft-foreground` on `background`: at least 4.5:1. success outline and ghost: text on the page. - `success-soft-foreground` on `card`: at least 4.5:1. success outline and ghost: text on cards. - `success-soft-foreground` on `muted`: at least 4.5:1. success text on muted fills, like a website card's values and comparison dots. - `success-soft-foreground` on `popover`: at least 4.5:1. success outline and ghost: text in menus, dialogs, and popovers. - `color-mix(in oklab, var(--success-soft-foreground), var(--background) 35%)` on `background`: at least 3:1. success outline: the border on the page (tone-line). - `warning-foreground` on `warning`: at least 4.5:1. warning solid: text on the fill. - `warning-foreground` on `warning-hover`: at least 4.5:1. warning solid: text on the hover fill. - `warning` on `background`: at least 3:1. warning solid: the fill on the page (checked checkbox and switch). - `warning` on `card`: at least 3:1. warning solid: the fill on cards (checked checkbox and switch). - `warning-soft-foreground` on `warning-soft`: at least 4.5:1. warning soft: text on the fill. - `warning-soft-foreground` on `color-mix(in oklab, var(--warning-soft), var(--warning) 12%)`: at least 4.5:1. warning soft: text on the hover fill (tone-soft-hover). - `warning-soft-foreground` on `background`: at least 4.5:1. warning outline and ghost: text on the page. - `warning-soft-foreground` on `card`: at least 4.5:1. warning outline and ghost: text on cards. - `warning-soft-foreground` on `muted`: at least 4.5:1. warning text on muted fills, like a website card's values and comparison dots. - `warning-soft-foreground` on `popover`: at least 4.5:1. warning outline and ghost: text in menus, dialogs, and popovers. - `color-mix(in oklab, var(--warning-soft-foreground), var(--background) 35%)` on `background`: at least 3:1. warning outline: the border on the page (tone-line). ## Access List > Manage who has access and what they can do. - Name: `access-list` - Status: beta - Categories: people, forms, display - File: the install writes `access-list.tsx` to your `ui` alias, `@/components/ui` by default. - npm packages: `@base-ui/react`, `lucide-react`. The install adds them. - Other registry items: `@txbrendan/motion`, `@txbrendan/button`, `@txbrendan/menu`, `@txbrendan/person`, `@txbrendan/tool-button`, `@txbrendan/tooltip`. The install adds them. - Gallery: https://txbrendan.dev/components/access-list - Registry item: https://txbrendan.dev/r/access-list.json ### Install 1. Add the registry to `components.json`. Do this first, once: ```bash npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json" ``` The command writes this entry. You can also add it by hand: ```json { "registries": { "@txbrendan": "https://txbrendan.dev/r/{name}.json" } } ``` Without this step, the shadcn CLI looks up `@txbrendan` in a public list that txbrendan/ui does not control. 2. Add the theme once per app. It replaces your color tokens with ones checked for contrast: ```bash npx shadcn@latest add @txbrendan/theme ``` 3. Add the component: ```bash npx shadcn@latest add @txbrendan/access-list ``` ### Use it for - The people part of a share sheet: who can open a page, a plan, or a file, and whether they view, comment, or edit. - Each entry is a person, an email with no account, a phone number, or the link. Give each an id. - Each row's level is a menu: Can view, Can comment, Can edit, then Remove access. A change applies at once, and ⌘Z takes it back for 8 seconds. - Remove access shows Removed with Undo on the row. onRemove runs once the window ends. - Set asked on an entry to show a request. Requests come first, with Approve and Decline in the row. - Guests view or comment. Set guestsCanEdit when the owner lets them edit. The link and a phone number only view. - Put it in a Card with className="gap-0 py-0". Its rows bring their own padding and lines. ### Avoid it for - Picking people to share with. Use a token field or a search above the list. - Roles in a team, like admin or member. This is about access to one thing. - Setting manage on someone who cannot change access. Pass manage={false} and the levels are plain words. ### Props - `entries`: `AccessEntry[]`. Required. { id, person?, photo?, email?, phone?, link?, meta?, guest?, owner?, level?, asked? }. level and asked are "view" | "comment" | "edit". - `guestsCanEdit`: `boolean`. Default `false`. The owner lets guests edit. Off, guests view or comment. - `manage`: `boolean`. Default `true`. You can change access. Off, levels are plain words and requests have no buttons. - `onLevel`: `(id: string, level: "view" | "comment" | "edit") => void`. A level changed, at once. It runs again with the old level if the change is undone. - `onRemove`: `(id: string) => void`. Removed, once the 8-second undo window ends. - `onApprove`: `(id: string, level: "view" | "comment" | "edit") => void`. A request approved, once the undo window ends, with the level given. - `onDecline`: `(id: string) => void`. A request declined, once the undo window ends. - `className`: `string`. Extra classes on the list. Later classes win. ### Accessibility - A list of rows. Each level is a menu button named by the person and the level, like "Kate Clarke can edit". - In the menu, the arrow keys move without wrapping, Enter chooses, and Esc closes it and puts focus back on the button. The current level is a checked menuitemradio. - After Remove access, the same button says "Kate Clarke removed" and is the Undo, armed 1.2 seconds after a key press. It no longer says it opens a menu. - Decline is an icon named "Decline" plus the name, and its tooltip says Decline. Approve's name carries the name too. - Each result is said once in the page's polite live region. ⌘Z or Ctrl+Z takes back the newest change, outside a text field. ### Examples #### In a share sheet ```tsx "use client"; import { useState } from "react"; import { AccessList, type AccessEntry } from "@/components/ui/access-list"; import { Card } from "@/components/ui/card"; export function Access() { const [entries, setEntries] = useState([ { id: "sam", person: "Sam Carter", meta: "Design lead", owner: true, level: "edit" }, { id: "rob", person: "Rob Kerr", meta: "Client, guest", guest: true, asked: "comment" }, { id: "kate", person: "Kate Clarke", level: "edit" }, { id: "link", link: true, meta: "View only · 3 visitors", level: "view" }, ]); const set = (id: string, change: Partial) => setEntries((all) => all.map((entry) => (entry.id === id ? { ...entry, ...change } : entry))); const drop = (id: string) => setEntries((all) => all.filter((entry) => entry.id !== id)); return ( set(id, { level })} onRemove={drop} onApprove={(id, level) => set(id, { asked: undefined, level })} onDecline={drop} /> ); } ``` ## Accordion > A stack of headings that each open and close a section below them, one at a time or several at once. - Name: `accordion` - Status: beta - Categories: layout - File: the install writes `accordion.tsx` to your `ui` alias, `@/components/ui` by default. - npm packages: `@base-ui/react`, `class-variance-authority`. The install adds them. - Other registry items: `@txbrendan/motion-timing`. The install adds them. - Gallery: https://txbrendan.dev/components/accordion - Registry item: https://txbrendan.dev/r/accordion.json ### Install 1. Add the registry to `components.json`. Do this first, once: ```bash npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json" ``` The command writes this entry. You can also add it by hand: ```json { "registries": { "@txbrendan": "https://txbrendan.dev/r/{name}.json" } } ``` Without this step, the shadcn CLI looks up `@txbrendan` in a public list that txbrendan/ui does not control. 2. Add the theme once per app. It replaces your color tokens with ones checked for contrast: ```bash npx shadcn@latest add @txbrendan/theme ``` 3. Add the component: ```bash npx shadcn@latest add @txbrendan/accordion ``` ### Use it for - Questions and answers, like an FAQ, where people read only the ones they need. - Long settings or details split into sections, so the page starts short. - Build each section from AccordionItem, AccordionTrigger, and AccordionPanel. Give each item a value when you control which ones are open. - Add multiple when people may want to compare two sections side by side. - Add hiddenUntilFound so the browser's find-in-page can search closed sections and open the match. - Panels can contain images, links, lists, and controls. Use keepMounted on a panel with local form state so closing it preserves edits. ### Avoid it for - Content everyone needs to read. Show it on the page. - Switching between views of the same thing. Use tabs. - A single section that shows more. Use a details element instead. - Steps that must happen in order. Use a page per step, or step dots. ### Props - `Accordion`: `{ value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[], details) => void; multiple?: boolean; disabled?: boolean; hiddenUntilFound?: boolean }`. Root. Holds which sections are open. Takes value, defaultValue, onValueChange, multiple, disabled, and hiddenUntilFound. - `size`: `"sm" | "md" | "lg"`. Default `"md"`. Accordion. Row height and text size: 36, 44, or 56px rows at the default density. - `multiple`: `boolean`. Default `false`. Accordion. Lets more than one section be open at once. - `value`: `string[]`. Accordion. The open items' values, controlled. Use it with onValueChange. - `defaultValue`: `string[]`. Accordion. The items open at the start when you do not control it. - `onValueChange`: `(value: string[], details) => void`. Accordion. Called with the open items' values when one opens or closes. - `hiddenUntilFound`: `boolean`. Default `false`. Accordion or AccordionPanel. Keeps closed panels in the page with hidden='until-found', so find-in-page can open them. - `disabled`: `boolean`. Default `false`. Accordion or AccordionItem. Blocks opening and closing. It dims. - `AccordionItem`: `ComponentProps<"div"> & { value?: string }`. Part. One section. A line separates it from the next. value names it for value and defaultValue. - `AccordionTrigger`: `ComponentProps<"button">`. Part. The heading's button. It renders inside an \

and adds a chevron that turns when the section opens. - `AccordionPanel`: `ComponentProps<"div">`. Part. The section's content. It grows open and shrinks closed. - `keepMounted`: `boolean`. Default `false`. AccordionPanel. Keeps the contents mounted while closed, preserving local field values. Hidden controls stay out of the tab order. - `className`: `string`. On any part. Later classes win. On AccordionPanel it goes on the inner box, where the padding is, so className='pb-6' works. ### Accessibility - Each trigger is a button inside an \

, so screen reader users can jump between sections by heading. - The button has aria-expanded and aria-controls. An open panel has role="region", named by its trigger. - Each trigger is its own tab stop. Enter and Space open and close it. Arrow keys do not move between triggers, as in the current ARIA pattern. - The chevron is hidden from screen readers. aria-expanded says whether a section is open. - The heading level is fixed at h3. If the page needs another level, pass render to the header in your copy of accordion.tsx. - The focus ring uses --ring, which the theme tests hold to 3:1 against the page and cards. - Opening grows the panel over --motion-duration-base while its contents slide into place and fade in over 1.5 times --motion-duration-slow. Closing fades the contents over --motion-duration-close. Reduced motion removes height, content, and chevron movement; Motion Off removes all transitions. ### Examples #### FAQ ```tsx Can I get a refund? Yes, within 30 days of your order. We send the money back to the card you paid with. How long does shipping take? Three to five working days in the US, and up to ten days anywhere else. ``` #### Editable values that survive closing a section ```tsx Account Billing Your plan, card, and past invoices. ``` #### Controlled ```tsx "use client"; import { useState } from "react"; function Sections() { const [open, setOpen] = useState([]); return ( setOpen(value as string[])}> First section Shown when open includes "one". ); } ``` ## Action Keys > Gives a list of actions their single-key shortcuts. It shows nothing, and it ignores keys while someone is typing. - Name: `action-keys` - Status: beta - Categories: actions - File: the install writes `action-keys.tsx` to your `ui` alias, `@/components/ui` by default. - Gallery: https://txbrendan.dev/components/action-keys - Registry item: https://txbrendan.dev/r/action-keys.json ### Install 1. Add the registry to `components.json`. Do this first, once: ```bash npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json" ``` The command writes this entry. You can also add it by hand: ```json { "registries": { "@txbrendan": "https://txbrendan.dev/r/{name}.json" } } ``` Without this step, the shadcn CLI looks up `@txbrendan` in a public list that txbrendan/ui does not control. 2. Add the theme once per app. It replaces your color tokens with ones checked for contrast: ```bash npx shadcn@latest add @txbrendan/theme ``` 3. Add the component: ```bash npx shadcn@latest add @txbrendan/action-keys ``` ### Use it for - Single-key shortcuts for a focused or hovered row's actions, like R for Reply. - Pass the same action objects you pass to HoverActions or BulkBar, so the key, the tooltip, and the button never drift apart. - Pass the toolbar's ref as within. A key then presses its button, so it shows the same spinner, result, and Undo a click does. - Turn it on for one row at a time with enabled. RowActions does this for you. - When two are on at once, like a row under the pointer and another with focus, a key goes to the one turned on last that has it. It never acts on both. - Put KeepTabInside last, right inside a popup of your own that keeps focus in, so a fast Tab cannot leave it. ### Avoid it for - Shortcuts with Cmd or Ctrl. It ignores keys with a modifier, so it never takes the browser's own. - Keys inside a field. It ignores keys while someone types in an input, a textarea, or editable text. - Turning it on for every row in a list at once. Only the row turned on last would answer, and no one could see which row that is. ### Props - `actions`: `ActionKey[]`. Required. The actions. Each with a shortcut gets its key. - `ActionKey`: `{ label: string; shortcut?: string; onClick?: () => void | Promise; disabled?: boolean }`. Type. shortcut is one key, matched in either case. A disabled action ignores its key. HoverAction and BulkBarAction fit it. - `enabled`: `boolean`. Default `true`. Listens for keys. Turn it on for the focused or hovered row only. When two are on, the one turned on last answers a key. - `within`: `RefObject`. The toolbar that holds the actions' buttons, like HoverActions' ref. A key presses the button whose data-shortcut matches. Without it, a key calls onClick. - `isPageKey`: `(event: KeyboardEvent, within?: HTMLElement | null) => boolean`. Helper. Whether a key is free for page shortcuts: no one is typing, no modifier is held, and focus is not in an open dialog or menu. - `KeepTabInside`: `{}`. Part, with no props. Put it last, right inside a popup that keeps focus in, not in a wrapper. Tab and Shift+Tab then wrap at the ends at once, so a fast Tab never lands on the page behind. Dialog, Alert Dialog, Sheet, Lightbox, Command Menu, and Notifications use it. ### Accessibility - Every key it listens for should be taught where people see the action, like HoverActions' tooltips, and set as aria-keyshortcuts on the button. ToolButton does both. - It ignores keys while focus is in a text field, a textarea, a select, or editable text, so typing never runs an action. - It ignores keys with Cmd, Ctrl, or Alt held, keys held down, and keys another handler already took. - It ignores keys while focus is in an open dialog or menu that its toolbar is not part of. - Single-key shortcuts can clash with speech input. Keep them on only while their row is hovered or focused, as WCAG 2.1.4 asks. - KeepTabInside wraps Tab only past the last stop and Shift+Tab only before the first. In between, the browser moves focus as it always does. A field that takes Tab itself, and calls preventDefault, keeps it. - Safari's Tab skips plain buttons and links, so it can pass the last stop KeepTabInside knows of. KeepTabInside then sends focus back in at once. - A native radio group with a checked choice is one Tab stop, at that choice, as in the browser. Shift+Tab wraps back to it. ### Examples #### Keys for a focused row (needs @txbrendan/hover-actions) ```tsx import { Pencil, Reply } from "lucide-react"; import { useRef } from "react"; import { HoverActions, type HoverAction } from "@/components/ui/hover-actions"; function MessageActions({ focused, actions }: { focused: boolean; actions: HoverAction[] }) { const bar = useRef(null); return ( <> ); } , label: "Reply", shortcut: "R" }, { icon: , label: "Edit", shortcut: "E" }]} /> ``` ## Agreement > Consent and codes of conduct: an "I agree" button that records who and when, never a checkbox. Once agreed, the record shows in its place with the person's photo. - Name: `agreement` - Status: beta - Categories: forms - File: the install writes `agreement.tsx` to your `ui` alias, `@/components/ui` by default. - Other registry items: `@txbrendan/avatar`, `@txbrendan/button`, `@txbrendan/motion-timing`. The install adds them. - Gallery: https://txbrendan.dev/components/agreement - Registry item: https://txbrendan.dev/r/agreement.json ### Install 1. Add the registry to `components.json`. Do this first, once: ```bash npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json" ``` The command writes this entry. You can also add it by hand: ```json { "registries": { "@txbrendan": "https://txbrendan.dev/r/{name}.json" } } ``` Without this step, the shadcn CLI looks up `@txbrendan` in a public list that txbrendan/ui does not control. 2. Add the theme once per app. It replaces your color tokens with ones checked for contrast: ```bash npx shadcn@latest add @txbrendan/theme ``` 3. Add the component: ```bash npx shadcn@latest add @txbrendan/agreement ``` ### Use it for - Codes of conduct, consents, and terms that someone must agree to, by name. - Store the record in onAgree and pass it back as agreed: { person, photo, at }. The record showing in place is the confirmation, so no toast repeats it. - A guardian agreeing for a junior passes forPerson. The record then says who it was for. - A consent that can be taken back, like photo consent, passes onWithdraw. - A required one left unagreed takes error, set when the form is sent. Its title and button edge turn red, its button glows once, and nothing changes size. Say what is missing on the button that was pressed, with shake() and new words, so it never moves. - In a stack, each sits on the sheet with no box, divided by a faint line. Pass divider={false} on the first one. The sheet's own Continue is the main action, so the buttons stay outlines. - When it is the surface's only decision, pass main, and the button is solid brand. ### Avoid it for - Settings that are just on or off. Use a switch. - Picking among options. Use choice chips or a radio group. - A checkbox for consent. A button that records who and when says more, and cannot be ticked by accident. ### Props - `title`: `ReactNode`. What is agreed to, like "Code of conduct". It names the agreement. - `text`: `ReactNode`. The short version, in plain words. - `link`: `{ label: ReactNode; href?: string; onClick?: () => void }`. The full text: a link with href, or a button with onClick. - `forPerson`: `{ person: string; photo?: string }`. Who it is for, when someone agrees on their behalf. - `agreed`: `{ person: string; photo?: string; at?: string | Date } | null`. The record. Once set, it shows in place of the button: "Agreed by Anna Price · Tue 29 Sept, 18:04". - `onAgree`: `() => void`. Store the record here, then pass it back as agreed. - `onWithdraw`: `() => void`. Shows Withdraw on the record. Clear agreed here. - `error`: `ReactNode`. For a required one left unagreed. The title and button edge turn red and the button glows once, with no change in size. The words describe the button for screen readers. - `divider`: `boolean`. Default `true`. A faint line above it that fades out at both ends. Pass false on the first of a stack. - `agreeLabel`: `ReactNode`. Default `"I agree"`. The button's words. - `main`: `boolean`. Default `false`. The surface's only decision: the button is solid brand. Off, it is an outline. - `size`: `"sm" | "md" | "lg"`. Default `"md"`. Text, button, and photo size. - `loading`: `boolean`. Default `false`. The button shows a spinner while the record is saved. - `disabled`: `boolean`. Default `false`. Blocks the button and Withdraw. - `locale`: `string`. Default `"en-GB"`. Formats the record's date and time. - `timeZone`: `string`. The zone the record's time shows in, like "Europe/London". By default, wherever it renders. Pass one when a server renders the page. - `className`: `string`. Classes for the root. ### Accessibility - The root is a group named by the title, so each "I agree" in a stack is heard with what it agrees to. - An error is described on the button. It is not announced here: the press that found it says it out loud, like a role="alert" beside the button that was pressed. - The glow follows the --motion-\* tokens, so reduced motion skips it. The red title and edge stay. - Once agreed, focus moves from the button to the record, so screen readers read who agreed and when. After Withdraw it moves back to the button. - The record's date is a \