# Forms (/docs/getting-started/forms) ## Overview [#overview] Cubby UI provides three components for building accessible forms: [Form](/docs/components/form), [Field](/docs/components/field), and [Fieldset](/docs/components/fieldset). These wrap Base UI's form primitives, providing native constraint validation, custom validation, accessible labeling, error display, and integration with third-party libraries. ## Quick start [#quick-start] ```tsx import { Button } from "@/components/ui/cubby-ui/button"; import { Field, FieldControl, FieldError, FieldLabel, } from "@/components/ui/cubby-ui/field"; import { Form } from "@/components/ui/cubby-ui/form";
console.log(values)}> Email Email is required Enter a valid email
; ``` ## Labeling form controls [#labeling-form-controls] Every form control needs an accessible label. The correct label component depends on the control type. ### Input-type controls [#input-type-controls] Use `FieldLabel` to label these controls: * [Input](/docs/components/input) * [Number Field](/docs/components/number-field) * [Combobox](/docs/components/combobox) (when the input is outside the popup) * [Checkbox](/docs/components/checkbox) * [Radio](/docs/components/radio-group) * [Switch](/docs/components/switch) For Checkbox, Radio, and Switch, wrap the control inside `FieldLabel` for implicit labeling: ```tsx I accept the terms ``` ### Trigger-based controls [#trigger-based-controls] These controls have their own label component. Do **not** use `FieldLabel` for them: * [Select](/docs/components/select): use `SelectLabel` * [Slider](/docs/components/slider): use `SliderLabel` ```tsx ``` ### Descriptions [#descriptions] `FieldDescription` adds accessible helper text to any field, regardless of control type: ```tsx Used for notifications and reminders ``` ## Grouping controls with Fieldset [#grouping-controls-with-fieldset] Use `Fieldset` with `FieldsetLegend` when a single label applies to multiple controls, such as radio groups, checkbox groups, or multi-thumb sliders. Compose with the `render` prop to merge the fieldset element with a group component: ```tsx
}> Role Member Admin
``` Use `FieldItem` to wrap each individual option so it gets its own label and description. ## Validation [#validation] ### Constraint validation [#constraint-validation] Use native HTML attributes on `FieldControl` (`required`, `pattern`, `minLength`, `type`, `min`, `max`, etc.) and `FieldError` to display the browser's native error message: ```tsx Website ``` ### Per-state custom messages [#per-state-custom-messages] To override native messages (for i18n or branding), use multiple `FieldError` components with the `match` prop. Each one renders only for the specified validity state: ```tsx Password Password is required Must be at least 8 characters ``` Available `match` values: `valueMissing`, `typeMismatch`, `patternMismatch`, `tooShort`, `tooLong`, `rangeUnderflow`, `rangeOverflow`, `stepMismatch`, `badInput`, `customError`. ### Custom validation [#custom-validation] Use the `validate` prop on `Field` for custom logic. Supports async functions: ```tsx { const taken = await checkUsername(value); return taken ? "Username is taken" : null; }} > Username ``` ### Server-side validation [#server-side-validation] Pass errors from your server to `Form`'s `errors` prop. Keys match field `name` attributes: ```tsx const [errors, setErrors] = React.useState({});
{ event.preventDefault(); const response = await submitToServer(new FormData(event.currentTarget)); if (response.errors) setErrors(response.errors); }} > ...
; ``` ## Submitting data [#submitting-data] ### Native onSubmit [#native-onsubmit] Use `onSubmit` with `FormData` for standard form submission: ```tsx
{ event.preventDefault(); const formData = new FormData(event.currentTarget); // send formData }} /> ``` ### JavaScript object with onFormSubmit [#javascript-object-with-onformsubmit] Use `onFormSubmit` to receive values as a plain object. `preventDefault` is called automatically: ```tsx { // values is { name: "...", email: "...", ... } }} /> ``` ## Third-party library integration [#third-party-library-integration] ### React Hook Form [#react-hook-form] Use `Controller` to bridge RHF state with Field's `invalid`, `dirty`, and `touched` props. Use `onValueChange` on `FieldControl` (not `onChange`) and `match={!!error}` on `FieldError`: ```tsx ( Username {error?.message} )} /> ``` For RHF to focus invalid fields, forward `ref` to the underlying control - typically via `inputRef` on wrapper components (Select, Switch, Checkbox, RadioGroup) or directly as `ref` on input-like components. ### TanStack Form [#tanstack-form] Use `form.Field` (TanStack's field) to manage state, and `Field` (Cubby UI) for the UI. Note: the Base UI `Form` component isn't needed with TanStack Form - use a native `` element instead. ```tsx import { useForm, revalidateLogic } from "@tanstack/react-form"; const form = useForm({ defaultValues: { email: "" }, onSubmit: ({ value }) => { // submit value }, validationLogic: revalidateLogic({ mode: "submit", modeAfterSubmission: "change", }), validators: { onDynamic: ({ value }) => { const errors: Record = {}; if (!value.email) errors.email = "Email is required"; return Object.keys(errors).length === 0 ? undefined : { form: errors, fields: errors }; }, }, }); { event.preventDefault(); form.handleSubmit(); }} noValidate > {(field) => ( Email {field.state.meta.errors.join(",")} )} ; ``` ## Forms on elevated surfaces [#forms-on-elevated-surfaces] Forms inside a Card, Dialog, Sheet, Drawer, or popover need no changes. The default field fill is translucent, so it reads on the page and on raised surfaces alike: ```tsx Email } type="email" required /> ``` Pass `variant="outline"` to give fields the outline Button's fill and hairline edge; it works on any surface. The prop exists on Input, Textarea, NumberFieldGroup (not NumberFieldInput), InputGroup, OTPFieldInput, ComboboxInput, ComboboxChips, AutocompleteInput, SelectTrigger and the DatePicker and DateRangePicker triggers. Checkbox, RadioGroupItem, and Switch have no variant, since their resting grey reads on any surface. See the [Surfaces](/docs/getting-started/surfaces) docs for the full reasoning. ## Component reference [#component-reference] | Component | Use for | | ------------------------------------- | ----------------------------------------------------------------- | | [Form](/docs/components/form) | Form wrapper with `errors`, `onFormSubmit`, `validationMode` | | [Field](/docs/components/field) | Field wrapper with label, control, description, error, validation | | [Fieldset](/docs/components/fieldset) | Grouping related fields with a shared legend | # Installation (/docs/getting-started/installation) Cubby UI uses the [shadcn CLI](https://ui.shadcn.com/docs/cli) to add components to your project. Components are copied directly into your codebase, giving you full ownership and control. ## Prerequisites [#prerequisites] Before installing components, ensure your project has: * **React 19** or later * **Tailwind CSS 4** configured * **TypeScript** ## Quick Start [#quick-start] Cubby UI is listed in the official [shadcn registry directory](https://ui.shadcn.com/docs/directory), so the `@cubby-ui` namespace resolves automatically. There's no need to add it to your `components.json`. ### 1. Install Base UI [#1-install-base-ui] Cubby UI components are built on [Base UI](https://base-ui.com), a headless component library: **npm:** ```bash npm install @base-ui/react ``` **pnpm:** ```bash pnpm add @base-ui/react ``` **yarn:** ```bash yarn add @base-ui/react ``` **bun:** ```bash bun add @base-ui/react ``` ### 2. Add the theme [#2-add-the-theme] Cubby UI ships its theme as a standalone style (`@cubby-ui/style`) covering colors, status colors, radius, easings, and the surface elevation system. Choose the path that matches your project: New project Existing project Scaffold shadcn and install Cubby UI's theme as your project's base in a single command: **npm:** ```bash npx shadcn@latest init @cubby-ui/style ``` **pnpm:** ```bash pnpm dlx shadcn@latest init @cubby-ui/style ``` **yarn:** ```bash yarn dlx shadcn@latest init @cubby-ui/style ``` **bun:** ```bash bunx --bun shadcn@latest init @cubby-ui/style ``` This creates your `components.json` and writes the full theme into `globals.css`. Already have a shadcn project? Add the theme to it: **npm:** ```bash npx shadcn@latest add @cubby-ui/style ``` **pnpm:** ```bash pnpm dlx shadcn@latest add @cubby-ui/style ``` **yarn:** ```bash yarn dlx shadcn@latest add @cubby-ui/style ``` **bun:** ```bash bunx --bun shadcn@latest add @cubby-ui/style ``` ### 3. Add the root class [#3-add-the-root-class] Add a `root` class to your application wrapper. This creates a stacking context required by Base UI's portal-based components: ```tsx title="app/layout.tsx" export default function RootLayout({ children, }: { children: React.ReactNode; }) { return (
{children}
); } ``` ### 4. Add components [#4-add-components] Now you can add any Cubby UI component: **npm:** ```bash npx shadcn@latest add @cubby-ui/button ``` **pnpm:** ```bash pnpm dlx shadcn@latest add @cubby-ui/button ``` **yarn:** ```bash yarn dlx shadcn@latest add @cubby-ui/button ``` **bun:** ```bash bunx --bun shadcn@latest add @cubby-ui/button ``` This will: * Copy the component source code to your project * Install any required dependencies * Set up the necessary utility functions ## Adding Multiple Components [#adding-multiple-components] Add multiple components in a single command: **npm:** ```bash npx shadcn@latest add @cubby-ui/button @cubby-ui/input @cubby-ui/card ``` **pnpm:** ```bash pnpm dlx shadcn@latest add @cubby-ui/button @cubby-ui/input @cubby-ui/card ``` **yarn:** ```bash yarn dlx shadcn@latest add @cubby-ui/button @cubby-ui/input @cubby-ui/card ``` **bun:** ```bash bunx --bun shadcn@latest add @cubby-ui/button @cubby-ui/input @cubby-ui/card ``` Or install all components at once: **npm:** ```bash npx shadcn@latest add @cubby-ui/all ``` **pnpm:** ```bash pnpm dlx shadcn@latest add @cubby-ui/all ``` **yarn:** ```bash yarn dlx shadcn@latest add @cubby-ui/all ``` **bun:** ```bash bunx --bun shadcn@latest add @cubby-ui/all ``` ## What's Included in the Theme [#whats-included-in-the-theme] The `@cubby-ui/style` theme adds CSS variables for colors (including status colors), border radius variants, easing functions, the surface elevation system, and Base UI layout requirements. See [Theming](/docs/getting-started/theming) for full details. ## Project Structure [#project-structure] After adding components, your project structure will look like this: ``` components/ ui/ cubby-ui/ button.tsx input.tsx card.tsx hooks/ cubby-ui/ use-mobile.ts lib/ cubby-ui/ utils.ts components.json ``` The exact paths depend on your `components.json` configuration. ## Manual Installation [#manual-installation] If you prefer not to use the CLI, you can manually copy component source code from the documentation and install dependencies yourself. Each component's documentation page includes the full source code and dependency list. For the theme setup, copy the CSS variables from the [Theming](/docs/getting-started/theming) guide into your `globals.css`. ## Next Steps [#next-steps] * Browse the [Components](/docs/components) to see what's available * Learn about [Theming](/docs/getting-started/theming) to customize the look and feel * Check out individual component pages for usage examples # Introduction (/docs/getting-started/introduction) ## What is Cubby UI? [#what-is-cubby-ui] Cubby UI is a collection of opinionated components built on [Base UI](https://base-ui.com) with React 19, TypeScript, and Tailwind CSS 4. It adds styling and functionality defaults on top of Base UI's unstyled, accessible primitives. ## Why Cubby UI? [#why-cubby-ui] * **Opinionated styling** - Pre-styled components built on Base UI's unstyled, accessible primitives. No need to design from scratch. * **Common patterns built-in** - Abstracts common use cases so you don't have to wire up the same functionality repeatedly. * **Full ownership** - Components are copied into your project via the [shadcn registry](https://ui.shadcn.com/docs/registry), not imported from a package. Customize freely. ## Primitives and Composables [#primitives-and-composables] The component library is split into two groups, labelled on every component page and grouped in the sidebar: * **Primitives** - A styled wrapper around a single Base UI primitive. [Button](/docs/components/button), [Dialog](/docs/components/dialog), and [Select](/docs/components/select) take an unstyled, accessible primitive and add Cubby UI's defaults. If Base UI ships it, you'll find the styled version here. * **Composables** - A higher-level pattern composed from primitives, or an original component with no Base UI counterpart. [Command](/docs/components/command) builds a command menu on top of Dialog; [Circular Slider](/docs/components/circular-slider) is built from scratch. Composables list what they're **built on** so you can trace them back to their foundation. ## Other Options [#other-options] There are several great UI libraries built on Base UI. Here are some worth checking out: * [Shadcn/UI](https://ui.shadcn.com/create) - Shadcn/UI now offers all their ui components with Base UI as the component library. This is a great option if you are already using Shadcn/UI and want to use Base UI components. * [Coss UI](https://coss.com/ui/docs) - Great collection of components built by the team of Cal.com that is also built on top of Base UI. If you're looking for chart components, check out [Evil Charts](https://evilcharts.com/). ## Getting Started [#getting-started] Ready to start building? Head to the [Installation](/docs/getting-started/installation) guide to add Cubby UI to your project. # Surfaces (/docs/getting-started/surfaces) Open a dropdown and it should read as floating above the page. Open a popover *inside* a dialog and it should still read as floating above the dialog. Surfaces is the system that makes that happen automatically, at any depth. Most of the time you never touch it. ## How elevation works [#how-elevation-works] Every floating component (Popover, Dropdown, Dialog, Sheet, Drawer, and the rest) sits on a **level** from 1 to 8. The deeper something is nested, the higher its level. A card sits low, a dialog higher, a popover opened inside that dialog higher still. Each step up gets a lighter surface and a stronger shadow, so every layer reads as lifted above the one behind it. [Component omitted for brevity] There are eight levels because a nested case can go surprisingly deep, and the ladder should never run out. Most apps only ever reach for three or four. One thing is worth knowing up front, because light and dark behave differently: * **Light mode** keeps levels 3 and up pure white. The shadow alone does the lifting. * **Dark mode** brightens the surface at each level, from `oklch(0.205)` at level 1 to `oklch(0.402)` at level 8, so each layer is visibly lighter than the one below. ## The two knobs [#the-two-knobs] Every elevated component takes two props: * `level` picks the **surface color**: which rung of the 1 to 8 ladder it sits on. * `shadowLevel` picks the **shadow weight**: how heavily it casts. They move independently. A wide dialog can sit on a mid surface but throw a heavy shadow; a small popover can sit higher yet cast a lighter one. Drag both and watch what changes: [Component omitted for brevity] In light mode the surface stops changing above level 3 (they are all white) while the shadow keeps growing. In dark mode the surface color climbs with every level. That split is the whole idea: light mode leans on shadow, dark mode leans on color. Toggle the site theme to compare. ## Using it [#using-it] Here is the part that matters most: "*you rarely set either prop.** The defaults are tuned for the common case. A popover already sits at the right level; a dialog already sits higher. The one time you reach for `level` is when you nest deeper than usual, like opening a popover from inside a dialog: ```tsx // Default popover: level and shadow are already set for you. Open Default popover // Inside a Dialog (which sits at level 5), bump the inner popover up. Open inside dialog Pops above the dialog ``` Bump `level` (not `shadowLevel`) when you nest. `shadowLevel` stays pinned per component, so a popover keeps reading like a popover no matter how deep it goes. > **That is the whole API for most people.** Everything below is reference: the raw tokens, four helpers for building your own surfaces, and the knobs for retuning the system. Skip it until you need it. ## Reference [#reference] ### The ladder [#the-ladder] Eight surface colors, drawn here in your current theme: [Component omitted for brevity] | Level | Light | Dark | Typical use | | ----------- | ------------------ | ------------------ | ------------------------------------ | | `surface-1` | `oklch(0.99 0 0)` | `oklch(0.205 ◇ ◆)` | Page background | | `surface-2` | `oklch(0.995 0 0)` | `oklch(0.235 ◇ ◆)` | Inline toolbars, menubars | | `surface-3` | `oklch(1 0 0)` | `oklch(0.264 ◇ ◆)` | Cards, popovers, dropdowns, tooltips | | `surface-4` | `oklch(1 0 0)` | `oklch(0.293 ◇ ◆)` | Tabs indicator, transitional | | `surface-5` | `oklch(1 0 0)` | `oklch(0.321 ◇ ◆)` | Dialogs, sheets, drawers | | `surface-6` | `oklch(1 0 0)` | `oklch(0.348 ◇ ◆)` | Transitional | | `surface-7` | `oklch(1 0 0)` | `oklch(0.375 ◇ ◆)` | Popovers nested inside dialogs | | `surface-8` | `oklch(1 0 0)` | `oklch(0.402 ◇ ◆)` | Maximum elevation (rare) | `◇` is `var(--neutral-chroma)` and `◆` is `var(--neutral-hue)`; both come from the [brand tint](#brand-tint). The named tokens you use elsewhere alias onto the ladder, so tuning the ladder updates all of them: ```css --background: var(--surface-1); --card: var(--surface-3); /* Card and Popover share level 3 */ --popover: var(--surface-3); --sidebar: var(--surface-1); ``` ### Shadows [#shadows] `shadowLevel` tracks the visual weight of the element, not its surface level. Pick it by size: * Under \~400px wide: `3` * \~400 to 800px: `5` * Over \~800px or fullscreen: `7` * Never let `shadowLevel` exceed `level` by more than 2. | `shadowLevel` | Drop layers | Feel | | ------------- | ------------------- | ---------------------------------------- | | `1` | 0 (just a 1px ring) | Barely there | | `2` | 1 | Tooltip, quiet | | `3` | 2 | **Dropdown, popover, card** | | `4` | 3 | Toast, floating elements with no trigger | | `5` | 4 | **Dialog, sheet** | | `6` | 5 | Transitional | | `7` | 6 | Dramatic (hero modals) | | `8` | 7 | Maximum gravity (rare) | In dark mode the recipe also adds an inset highlight and ring, giving surfaces a lit-from-above edge. In light mode those insets are no-ops and the drop shadow does everything. Set the weight through the `shadowLevel` prop (` [Component omitted for brevity] `) or as the second argument to any helper below (`solidSurface(3, 5)` is a level-3 surface carrying a level-5 shadow), rather than reaching for a shadow utility class. That last part is not a style preference. A shadow applied by theme key or by a bracketed arbitrary value lands in tailwind-merge's `shadow-color` group rather than its `shadow` group, so a later `shadow-none` from a consumer never wins and stylesheet order silently decides. The helpers use the one form tailwind-merge treats as a shadow, which is why passing a level through them stays overridable. ### Component defaults [#component-defaults] You do not need these day to day; they are what each component already uses. Reach in only to match a custom element to a built-in one. Default level and shadow for every component | Component | Default `level` | Default `shadowLevel` | | -------------------- | --------------- | -------------------------------- | | Tooltip | `2` | `2` | | Card | `3` | `1` | | Table | `3` | `1` | | DataTable | `3` | `1` | | Popover | `3` | `3` | | Dropdown menu | `3` | `3` | | Dropdown sub-menu | parent's | `3` | | Context menu | `3` | `3` | | Context sub-menu | parent's | `3` | | Menubar (the bar) | `2` | `2` | | Menubar dropdown | `3` | `3` | | Menubar sub-menu | parent's | `3` | | NavigationMenu (bar) | `2` | `2` | | NavigationMenu popup | `3` | `3` | | NavigationMenu sub | `3` | `3` | | Select | `3` | `3` | | Toast | `3` | `4` (heavier, floats unattached) | | Dialog | `5` | `5` | | Alert dialog | `5` | `5` | | Sheet | `5` | `5` | | Drawer | `5` | `5` | | Command | `3` | `3` | | Combobox | `3` | `3` | | Autocomplete | `3` | `3` | The flat-card family (Card, Table, DataTable) shares `solidSurface(3, 1)`: a surface-3 background with a 1px rim and no drop shadow. They are embedded containers, not floating popups, so they read as lifted just enough to define an edge rather than casting a shadow over content. Code blocks and ComponentPreview trays sit lower, as flat `bg-muted` wells with no shadow. ### State overlays [#state-overlays] Hover and selected backgrounds are translucent overlays, so they lift whatever surface they sit on by a fixed perceptual step, whatever its level: ```css /* light */ --surface-hover: oklch(0 0 0 / 0.06); --surface-selected: oklch(0 0 0 / 0.1); /* dark */ --surface-hover: oklch(1 0 0 / 0.06); --surface-selected: oklch(1 0 0 / 0.1); ``` ```tsx
Hovered row
Active row
``` Because they are overlays and not fixed colors, hover on a `surface-3` row brightens by the same amount as on a `surface-7` row. Hover always reads as hover, even deeply nested. The values (6% for hover, 10% for selected) are matched to fluid-functionalism. ## Building your own surface [#building-your-own-surface] Making a custom floating container? Four helpers in `@/registry/default/lib/elevated` return Tailwind class strings. Drop them on any element you want elevated. Pick by situation: | Helper | When | | ----------------- | --------------------------------------------------------------------- | | `solidSurface` | Most cases. The default choice. | | `elevatedSurface` | Sticky or opaque children sit near the edges (labels, inset footers). | | `surfaceClasses` | The surface already defines its own edge (a real CSS border). | | `flushSurface` | A sheet or drawer pinned against a screen edge. | ### solidSurface(level, shadowLevel?) [#solidsurfacelevel-shadowlevel] Returns `bg-surface-N`, the combined drop and rim shadow, and exposes `--popup-surface` for descendants. Use it for any floating container without sticky or opaque children near its edges. ```tsx import { solidSurface } from "@/registry/default/lib/elevated"; function Card({ children }) { return
{children}
; } ``` ### elevatedSurface(level, shadowLevel?) [#elevatedsurfacelevel-shadowlevel] Same look as `solidSurface`, but it paints the rim on an `::after` layer above the content instead of in the box-shadow. Reach for it when sticky or opaque children sit near the edges: with `solidSurface` the rim lives in the box-shadow, so an opaque child at an edge renders on top and hides it. The two common cases are sticky group labels (Select, Combobox, Autocomplete) and opaque inset footers (Dialog, Sheet, Drawer, where a `bg-muted` footer would cover the bottom rim). ```tsx import { elevatedSurface } from "@/registry/default/lib/elevated"; function MenuWithStickyHeader({ children }) { return (
Sticky header
{children}
); } ``` The host must be positioned and have a border-radius so the rim clips correctly. ### surfaceClasses(level, shadowLevel?) [#surfaceclasseslevel-shadowlevel] The primitive the others build on: just `bg-surface-N` and the downward drop shadow, no rim. Reach for it directly when the surface defines its own edge some other way (a real CSS border, a single-edge rim). ```tsx import { surfaceClasses } from "@/registry/default/lib/elevated"; function PlainSurface({ children }) { return
{children}
; } ``` ### flushSurface(level, innerEdge) [#flushsurfacelevel-inneredge] For viewport-flush containers: the `default` Sheet and Drawer variants that slide in pinned against a screen edge. Three of their four edges sit at the viewport boundary, so `solidSurface`'s always-downward shadow and four-edge rim point the wrong way. `flushSurface` instead casts the drop shadow toward the inner, content-facing edge, and paints a single-edge rim on that same edge. Pass the content-facing edge directly, or derive it from the attach side with `INNER_EDGE_FROM_ATTACH_SIDE` (`right → left`, `bottom → top`, and so on). ```tsx import { flushSurface, INNER_EDGE_FROM_ATTACH_SIDE, } from "@/registry/default/lib/elevated"; // Right-flush sheet: shadow and rim on the left (inner) edge. function RightSheet({ children }) { return (
{children}
); } // Or derive the edge from the attach side: // flushSurface(5, INNER_EDGE_FROM_ATTACH_SIDE[side]) ``` ## The chrome surface [#the-chrome-surface] One surface sits deliberately off the ladder. `--chrome` is a near-black fill in **both** themes, for persistent app chrome (a header bar, a command bar) and for floating labels that should read as instrument rather than page. `TooltipContent` exposes it as `variant="chrome"`. It is not an inverse surface. Inverting in dark would put a white slab at roughly 116 times the page's luminance into chrome you cannot scroll away from, so both themes stay dark. In light the fill borrows `--neutral`, the same black as a solid `neutral` Button. In dark it steps *below* the page rather than above it, because up is where every card lives and a lifted near-black slab reads as one more card. Put `data-surface="chrome"` on the container and use ordinary page classes inside. That attribute re-points nine tokens for the whole subtree: `--foreground`, `--muted-foreground`, `--border`, `--muted`, `--accent` and `--accent-foreground`, `--input`, and the hover and active washes. Anything painting from those needs no per-control classes: ```tsx import { CHROME_SURFACE } from "@/registry/default/lib/elevated";
Primary label Secondary label
; ``` ### The edge is opt-in [#the-edge-is-opt-in] `CHROME_SURFACE` paints the fill and nothing else, the same way `SURFACE_BG` does for a ladder rung. The edge is a separate utility you add when the surface needs one: ```tsx
``` Add it for anything large: a header bar, a command bar, a chrome Select or dropdown. Leave it off for anything small, the way the chrome Tooltip does, because an edge on a two-line label is more boundary than there is object and a tooltip already carries its shape through its text and arrow. In dark, `--chrome-shadow` is not decoration. The chrome fill measures **1.08:1** against the dark page, so on a large surface that token is the only thing separating the two. In light the same pair measures **16.3:1**, the step alone is the boundary, and the token collapses to the ladder's ring-without-drop rung, which keeps the surface flush rather than raised. The edge keeps a chrome surface **flush**, not raised: there is no drop layer in it, so a header bar sits in the page rather than above it. A chrome surface that genuinely floats needs its own sibling token rather than a per-element override, because a custom property's `var()`s are substituted where the property is declared, not where it is used. `--primary` is left alone on purpose, so a primary Button keeps its brand fill anywhere. `bg-chrome` is the only chrome utility, for the fill itself. There is deliberately no `bg-chrome-hover` beside it: a second idiom for the same job is what the attribute exists to remove. Two things to know: * **Portalled content does not inherit it.** Menus, dialogs and tooltips render at the document body, so a floating component that wants this look has to carry the attribute itself. That is exactly what `TooltipContent variant="chrome"` does. * **The edge is not the ladder's.** `--surface-rim-*` and the ring baked into `--surface-shadow-*` both assume a fill lighter than the page. This fill is darker, so the ladder's ring lands lighter than the very fill it should define and the boundary blurs. `--chrome-shadow` replaces it, carrying the outer boundary and the top-edge specular in one value. It is a box-shadow value, not a color: writing a bare color into it voids the entire `box-shadow`, because a single invalid layer invalidates the whole declaration. Because chrome is off the ladder, `level` and `shadowLevel` do not apply to either helper. ## Customizing the system [#customizing-the-system] ### Brand tint [#brand-tint] Two variables at the top of `:root` tint every neutral in the system, so you can retune the whole thing from one place: ```css --neutral-hue: 275; /* hue angle 0–360. Cool purple-blue. */ --neutral-chroma: 0.004; /* tint strength for mid-tone neutrals */ --neutral-chroma-low: 0.002; /* tint strength near the lightness extremes */ ``` Change `--neutral-hue` to rotate the entire palette without touching individual tokens: * `0 to 90`: warm (red, orange, amber, yellow). Try \~70 for amber. * `90 to 180`: green. * `180 to 270`: cool (cyan, blue). The primary lives at 250. * `270 to 330`: cool purple and violet. The neutral tint sits at 275. * `330 to 360`: pink and magenta, heading back toward warm. `--neutral-chroma` around `0.005 to 0.01` is the visibility floor; we sit just under at `0.004` for a quieter feel. ### Tracking the level from descendants [#tracking-the-level-from-descendants] When you set `level`, the helpers expose `--popup-surface` on that element. Children can match the surface color without hardcoding a level: ```tsx // Arrow that matches whatever level the popup is at. // Sticky label inside a Select that blends with the popup.
Group label
``` The `var(--popover)` fallback keeps things working if the variable is not set in some context. ## Design decisions [#design-decisions] Surfaces is inspired by [fluidfunctionalism.com/elevated](https://www.fluidfunctionalism.com/elevated), adapted to a prop-based API instead of a context, with a few deliberate divergences from a strict "tint everything on the ladder" reading. Light surfaces are pure neutral Every light-mode background (the surface ladder, `--muted`, `--secondary`, `--accent`, `--input`, sidebar surfaces) has chroma 0: literally neutral gray and white. The brand tint applies only to text foregrounds in light mode and to every dark-mode surface and token. Net effect: light mode reads as a clean canvas, dark mode carries the brand identity. It is a common pattern (Linear, Vercel, Stripe): tint on a near-white page reads as a wash rather than a signature, while in dark mode the same chroma has room to express. `--muted` lives off the ladder `--muted` is a standalone token (`oklch(0.95 0 0)` light, `oklch(0.24 ◇ ◆)` dark), not aliased to a surface level. The ladder represents *lifted* surfaces (up from the page); `--muted` represents the *recessed* category: inset wells, code blocks, command-palette wells, quiet differentiation strips. The reference aliases `--muted` to `surface-2` because its system has no recessed concept. Cubby does, so `--muted` stays separate. Form fields use `--input`, with an `outline` option Form fields come in two appearances, exposed as a `variant` prop on Input, Textarea, NumberField, InputGroup, OTP, Combobox, Autocomplete, Select and the date pickers: ```tsx // Default: a filled tint. Reads on the page, a Card, a Dialog, or a popover. // Outline: the outline Button's fill and edge. A style choice, usable anywhere. ``` The default is a translucent tint, the same family as the state overlays, so it adapts to whatever it sits on: ```css /* light */ --input: oklch(0 0 0 / 4.5%); /* dark */ --input: oklch(1 0 0 / 5%); ``` Since the tint holds on every surface, fields need no per-context switch. `outline` reuses the outline Button's `--outline` fill and `--outline-border` edge: the card fill with a hairline border in light mode, and a darker recessed fill with a faint 8% white ring in dark mode. Use it when fields should match outline buttons. Switch, Checkbox, and Radio have no `variant` prop. Their resting state is the shared `--control-off` grey (15% black, or 15% white in dark mode), which reads on any surface: the Switch track fills with it, and unchecked boxes and radios draw a 2px ring of it around a white middle. Pure black and white overlays stay untinted `--border`, `--surface-hover`, `--surface-selected`, the dark-mode shadow ring values, `--surface-shadow-*`, and the rest use literal `oklch(0 0 0)` or `oklch(1 0 0)` rather than tinted near-black or near-white. At translucent alpha they borrow their visible color from the tinted substrate beneath, so tinting the overlay itself contributes almost nothing while making the CSS harder to read. Inset patterns are mode-asymmetric The Command well uses `bg-surface-3 dark:bg-surface-1`. In light mode that is a lifted white card inside a gray strip; in dark mode it is a cutout to the page color. Each mode gets strong contrast its own way. A symmetric version (one token for both modes) has a weak mode by construction. ## Token reference [#token-reference] Every token lives on `:root` and is exposed as a Tailwind utility through `@theme inline`. Tune any of them in your `globals.css` to customize the system without touching the components. ```css /* Substrate */ --surface-1 .. --surface-8 /* surface colors */ --surface-shadow-1 .. -8 /* drop-shadow recipes, read by the helpers */ --surface-rim-1 .. -8 /* inset rim recipes, read by the helpers */ --surface-shadow-combined-1 .. -8 /* drop + rim in one value, for solidSurface */ /* State */ --surface-hover /* hover overlay */ --surface-active /* pressed overlay */ --surface-selected /* selected overlay */ --input /* translucent form-field fill */ --surface-rim-color /* single-edge rim color (flush sheets) */ --popup-surface /* set by the helpers, read by descendants */ /* Tint controls */ --neutral-hue /* one knob: rotates the whole tint */ --neutral-chroma /* tint strength for mid-tone neutrals */ --neutral-chroma-low /* tint strength near the gamut extremes */ /* Chrome surface (off the ladder) */ --chrome /* near-black fill in both themes → bg-chrome */ --chrome-foreground /* label color on that fill */ --chrome-muted-foreground /* secondary label color */ --chrome-hover /* the one wash: hover, active and fill */ --chrome-border /* hairline between controls */ --chrome-shadow /* the opt-in edge (a box-shadow value) */ /* Aliases */ --background → var(--surface-1) --card → var(--surface-3) --popover → var(--surface-3) --sidebar → var(--surface-1) ``` # Theming (/docs/getting-started/theming) ## Overview [#overview] Our styling system builds upon [shadcn/ui's CSS variables approach](https://ui.shadcn.com/docs/theming) with additional status colors and design tokens. ## Installation [#installation] CLI Manual For an existing shadcn project, add the theme: **npm:** ```bash npx shadcn@latest add @cubby-ui/style ``` **pnpm:** ```bash pnpm dlx shadcn@latest add @cubby-ui/style ``` **yarn:** ```bash yarn dlx shadcn@latest add @cubby-ui/style ``` **bun:** ```bash bunx --bun shadcn@latest add @cubby-ui/style ``` Starting a new project? Scaffold shadcn and install the theme in one command: **npm:** ```bash npx shadcn@latest init @cubby-ui/style ``` **pnpm:** ```bash pnpm dlx shadcn@latest init @cubby-ui/style ``` **yarn:** ```bash yarn dlx shadcn@latest init @cubby-ui/style ``` **bun:** ```bash bunx --bun shadcn@latest init @cubby-ui/style ``` This adds the theme CSS variables to your project. See [Installation](/docs/getting-started/installation) for the full setup. Copy and paste the following into your `globals.css` file: {/* THEME_CSS:START - generated by `pnpm run registry:sync` from registry/theme.css. Do not edit by hand. */} ```css title="globals.css" :root { --radius: 0.75rem; --neutral-hue: 270; --neutral-chroma: 0.004; --neutral-chroma-low: 0.002; /* Background colors, the page is surface-1, cards and popovers surface-3. */ --background: var(--surface-1); --foreground: oklch(0.3 var(--neutral-chroma) var(--neutral-hue)); --card: var(--surface-3); --card-foreground: var(--foreground); --popover: var(--surface-3); --popover-foreground: var(--foreground); /* Primary & accent colors */ --primary: oklch(0.68 0.16 232.65); --primary-foreground: oklch(1 0 0); --primary-soft-foreground: color-mix(in oklch, var(--primary), black 25%); --neutral: oklch(0.32 var(--neutral-chroma) var(--neutral-hue)); --neutral-foreground: oklch( 0.98 var(--neutral-chroma-low) var(--neutral-hue) ); /* Selection grey, so the primary stays reserved for actions. */ --emphasis: oklch(0.51 var(--neutral-chroma) var(--neutral-hue)); --emphasis-foreground: oklch(1 0 0); --secondary: oklch(0.93 0 0); --secondary-foreground: oklch(0.32 var(--neutral-chroma) var(--neutral-hue)); --muted: oklch(0.95 0 0); --muted-foreground: oklch(0.53 var(--neutral-chroma) var(--neutral-hue)); --accent: oklch(0.91 0 0); --accent-foreground: oklch(0.3 var(--neutral-chroma) var(--neutral-hue)); --destructive: oklch(0.53 0.19 25); --destructive-foreground: oklch( 0.98 var(--neutral-chroma-low) var(--neutral-hue) ); /* Same idea for `destructive-soft`'s label on its tinted plate. */ --destructive-soft-foreground: var(--destructive); --chrome: var(--neutral); --chrome-foreground: var(--neutral-foreground); --chrome-muted-foreground: color-mix( in oklab, var(--chrome-foreground) 71%, var(--chrome) ); --chrome-hover: color-mix( in oklab, var(--chrome-foreground) 10%, transparent ); --chrome-border: color-mix( in oklab, var(--chrome-foreground) 12%, transparent ); /* Dark recesses it with a translucent black, so it darkens any surface. */ --outline: var(--card); --outline-border: var(--border); --primary-hover: color-mix(in oklch, var(--primary), black 5%); --neutral-hover: color-mix(in oklch, var(--neutral), white 10%); --emphasis-hover: color-mix(in oklch, var(--emphasis), black 8%); --destructive-hover: color-mix(in oklch, var(--destructive), black 5%); --secondary-hover: color-mix(in oklch, var(--secondary), black 5%); --outline-hover: color-mix(in oklch, var(--outline), black 4%); /* Pressed, one step past hover in the same direction. */ --primary-active: color-mix(in oklch, var(--primary), black 8%); --neutral-active: color-mix(in oklch, var(--neutral), white 14%); --emphasis-active: color-mix(in oklch, var(--emphasis), black 12%); --destructive-active: color-mix(in oklch, var(--destructive), black 8%); --secondary-active: color-mix(in oklch, var(--secondary), black 8%); --outline-active: color-mix(in oklch, var(--outline), black 7%); /* Borders & inputs */ --border: color-mix(in oklab, var(--foreground) 10%, transparent); /* Translucent, so one value works on the page, a Card or a popup. */ --input: oklch(0 0 0 / 4.5%); --input-hover: oklch(0 0 0 / 7.5%); --input-active: oklch(0 0 0 / 10.5%); /* Shared off state: the Switch track fill and the Checkbox and Radio ring. */ --control-off: oklch(0 0 0 / 15%); --control-off-hover: oklch(0 0 0 / 19%); /* Empty part of a range. Keeps 3:1 or better against the emphasis fill. */ --control-track: oklch(0 0 0 / 9%); /* CodeBlock's inner panel, matching the outline variant. */ --code-panel: var(--outline); /* L 0.6 keeps the ring at 3:1 or better against the page. */ --ring: oklch(0.6 0.15 232.65); --scrollbar: oklch(0.87 0 0); --sidebar: var(--surface-1); --sidebar-foreground: var(--foreground); --sidebar-primary: var(--primary); --sidebar-primary-foreground: var(--primary-foreground); --sidebar-accent: var(--accent); --sidebar-accent-foreground: var(--accent-foreground); /* Opaque --border, for the resize rail where a translucent line is too faint. */ --sidebar-border: oklch(0.9 var(--neutral-chroma) var(--neutral-hue)); --sidebar-ring: var(--ring); /* Status colors */ --danger: oklch(0.97 0.04 25); --danger-foreground: oklch(0.55 0.18 25); --danger-border: oklch(0.9 0.1 25); --warning: oklch(0.98 0.06 85); --warning-foreground: oklch(0.58 0.14 85); --warning-border: oklch(0.92 0.1 85); --info: oklch(0.97 0.03 232.65); --info-foreground: oklch(0.5 0.13 232.65); --info-border: oklch(0.9 0.07 232.65); --success: oklch(0.97 0.04 145); --success-foreground: oklch(0.48 0.18 145); --success-border: oklch(0.9 0.1 145); --surface-1: oklch(0.99 0 0); --surface-2: oklch(0.995 0 0); --surface-3: oklch(1 0 0); --surface-4: oklch(1 0 0); --surface-5: oklch(1 0 0); --surface-6: oklch(1 0 0); --surface-7: oklch(1 0 0); --surface-8: oklch(1 0 0); --surface-hover: oklch(0 0 0 / 0.06); --surface-active: oklch(0 0 0 / 0.08); --surface-selected: oklch(0 0 0 / 0.1); --surface-rim-color: oklch(0 0 0 / 0); --surface-shadow-ring: oklch(0 0 0 / 0.045); /* crisp edge */ --surface-shadow-near: oklch(0 0 0 / 0.06); /* 1px blur (contact) */ --surface-shadow-mid: oklch(0 0 0 / 0.06); /* 3–6px blur */ --surface-shadow-far: oklch(0 0 0 / 0.05); /* 12–24px blur */ --surface-shadow-ambient: oklch(0 0 0 / 0.04); /* 48–96px blur */ --surface-shadow-1: 0 0 0 1px var(--surface-shadow-ring); --surface-shadow-2: 0 0 0 1px var(--surface-shadow-ring), 0 1px 1px -0.5px var(--surface-shadow-near); --surface-shadow-3: 0 0 0 1px var(--surface-shadow-ring), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid); --surface-shadow-4: 0 0 0 1px var(--surface-shadow-ring), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid); --surface-shadow-5: 0 0 0 1px var(--surface-shadow-ring), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far); --surface-shadow-6: 0 0 0 1px var(--surface-shadow-ring), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far), 0 24px 24px -12px var(--surface-shadow-far); --surface-shadow-7: 0 0 0 1px var(--surface-shadow-ring), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far), 0 24px 24px -12px var(--surface-shadow-far), 0 48px 48px -24px var(--surface-shadow-ambient); --surface-shadow-8: 0 0 0 1px var(--surface-shadow-ring), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far), 0 24px 24px -12px var(--surface-shadow-far), 0 48px 48px -24px var(--surface-shadow-ambient), 0 96px 96px -48px var(--surface-shadow-ambient); --surface-rim-1: 0 0 transparent; --surface-rim-2: 0 0 transparent; --surface-rim-3: 0 0 transparent; --surface-rim-4: 0 0 transparent; --surface-rim-5: 0 0 transparent; --surface-rim-6: 0 0 transparent; --surface-rim-7: 0 0 transparent; --surface-rim-8: 0 0 transparent; --surface-shadow-combined-1: var(--surface-shadow-1), var(--surface-rim-1); --surface-shadow-combined-2: var(--surface-shadow-2), var(--surface-rim-2); --surface-shadow-combined-3: var(--surface-shadow-3), var(--surface-rim-3); --surface-shadow-combined-4: var(--surface-shadow-4), var(--surface-rim-4); --surface-shadow-combined-5: var(--surface-shadow-5), var(--surface-rim-5); --surface-shadow-combined-6: var(--surface-shadow-6), var(--surface-rim-6); --surface-shadow-combined-7: var(--surface-shadow-7), var(--surface-rim-7); --surface-shadow-combined-8: var(--surface-shadow-8), var(--surface-rim-8); --chrome-shadow: var(--surface-shadow-1); } .dark { /* Dark-mode color overrides */ --foreground: oklch(0.94 var(--neutral-chroma) var(--neutral-hue)); --neutral: oklch(0.9 var(--neutral-chroma) var(--neutral-hue)); --neutral-foreground: oklch(0.13 var(--neutral-chroma) var(--neutral-hue)); /* 0.56 so white labels clear 4.5:1. */ --emphasis: oklch(0.56 var(--neutral-chroma) var(--neutral-hue)); --ring: oklch(0.75 0.15 232.65); --secondary: oklch(0.35 var(--neutral-chroma) var(--neutral-hue)); --secondary-foreground: oklch(0.94 var(--neutral-chroma) var(--neutral-hue)); --muted: oklch(0.24 var(--neutral-chroma) var(--neutral-hue)); --muted-foreground: oklch(0.73 var(--neutral-chroma) var(--neutral-hue)); --accent: oklch(0.31 var(--neutral-chroma) var(--neutral-hue)); --accent-foreground: oklch(0.95 var(--neutral-chroma) var(--neutral-hue)); --destructive: oklch(0.5 0.22 25); --destructive-foreground: oklch(1 0 0); /* Brightened so primary-soft's label stays readable on the darker plate. */ --primary: oklch(0.75 0.16 232.65); --primary-soft-foreground: color-mix(in oklch, var(--primary), white 30%); /* Same treatment for destructive-soft's label on its tinted plate. */ --destructive-soft-foreground: color-mix( in oklch, var(--destructive), white 30% ); --chrome: oklch(0.159 var(--neutral-chroma) var(--neutral-hue)); --chrome-foreground: var(--foreground); --chrome-muted-foreground: color-mix( in oklab, var(--chrome-foreground) 71%, var(--chrome) ); --chrome-hover: color-mix( in oklab, var(--chrome-foreground) 10%, transparent ); --chrome-border: color-mix( in oklab, var(--chrome-foreground) 12%, transparent ); /* Dark hover, mix toward white so hovered buttons brighten. */ --outline: oklch(0 0 0 / 0.3); --outline-border: oklch(1 0 0 / 0.08); --primary-hover: color-mix(in oklch, var(--primary), white 10%); --neutral-hover: color-mix(in oklch, var(--neutral), black 10%); --emphasis-hover: color-mix(in oklch, var(--emphasis), white 10%); --destructive-hover: color-mix(in oklch, var(--destructive), white 15%); --secondary-hover: color-mix(in oklch, var(--secondary), white 5%); --outline-hover: oklch(1 0 0 / 0.03); --primary-active: color-mix(in oklch, var(--primary), white 14%); --neutral-active: color-mix(in oklch, var(--neutral), black 14%); --emphasis-active: color-mix(in oklch, var(--emphasis), white 14%); --destructive-active: color-mix(in oklch, var(--destructive), black 8%); --secondary-active: color-mix(in oklch, var(--secondary), white 8%); --outline-active: oklch(1 0 0 / 0.05); --border: color-mix(in oklab, var(--foreground) 10%, transparent); --input: oklch(1 0 0 / 5%); --input-hover: oklch(1 0 0 / 9%); --input-active: oklch(1 0 0 / 13%); --control-off: oklch(1 0 0 / 15%); --control-off-hover: oklch(1 0 0 / 19%); --control-track: oklch(1 0 0 / 8%); /* Solid, since the copy button's fade paints with it. */ --code-panel: color-mix(in oklab, var(--muted), black 30%); --scrollbar: oklch(0.7 var(--neutral-chroma) var(--neutral-hue)); /* Only --sidebar-border needs a dark value; the rest are aliases. */ --sidebar-border: oklch(0.28 var(--neutral-chroma) var(--neutral-hue)); /* Status */ --danger: oklch(0.26 0.07 25); --danger-foreground: oklch(0.75 0.15 25); --danger-border: oklch(0.36 0.11 25); --warning: oklch(0.28 0.06 85); --warning-foreground: oklch(0.78 0.12 85); --warning-border: oklch(0.38 0.09 85); --info: oklch(0.26 0.06 232.65); --info-foreground: oklch(0.78 0.13 232.65); --info-border: oklch(0.36 0.09 232.65); --success: oklch(0.26 0.07 145); --success-foreground: oklch(0.72 0.15 145); --success-border: oklch(0.36 0.11 145); /* Surface ladder, progressive lightness with brand-hue tint */ --surface-1: oklch(0.205 var(--neutral-chroma) var(--neutral-hue)); --surface-2: oklch(0.235 var(--neutral-chroma) var(--neutral-hue)); --surface-3: oklch(0.264 var(--neutral-chroma) var(--neutral-hue)); --surface-4: oklch(0.293 var(--neutral-chroma) var(--neutral-hue)); --surface-5: oklch(0.321 var(--neutral-chroma) var(--neutral-hue)); --surface-6: oklch(0.348 var(--neutral-chroma) var(--neutral-hue)); --surface-7: oklch(0.375 var(--neutral-chroma) var(--neutral-hue)); --surface-8: oklch(0.402 var(--neutral-chroma) var(--neutral-hue)); /* State overlays flip to white-on-black */ --surface-hover: oklch(1 0 0 / 0.06); --surface-active: oklch(1 0 0 / 0.08); --surface-selected: oklch(1 0 0 / 0.1); --surface-rim-color: oklch(1 0 0 / 0.04); /* Dark-only tokens used by the rim recipe */ --surface-hi-base: oklch(1 0 0 / 0.01); --surface-hi-mid: oklch(1 0 0 / 0.02); --surface-hi-high: oklch(1 0 0 / 0.04); --surface-hi-peak: oklch(1 0 0 / 0.06); --surface-ring-base: oklch(1 0 0 / 0.02); --surface-ring-mid: oklch(1 0 0 / 0.04); --surface-ring-high: oklch(1 0 0 / 0.06); --surface-shadow-near: oklch(0 0 0 / 0.18); --surface-shadow-mid: oklch(0 0 0 / 0.16); --surface-shadow-far: oklch(0 0 0 / 0.14); --surface-shadow-ambient: oklch(0 0 0 / 0.12); /* Drop-shadow + outer-ring portion of the dark recipe (no insets here) */ --surface-shadow-1: 0 0 transparent; --surface-shadow-2: 0 1px 1px -0.5px var(--surface-shadow-near); --surface-shadow-3: 0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid); --surface-shadow-4: 0 0 0 1px oklch(0 0 0 / 0.1), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid); --surface-shadow-5: 0 0 0 1px oklch(0 0 0 / 0.12), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far); --surface-shadow-6: 0 0 0 1px oklch(0 0 0 / 0.14), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far), 0 24px 24px -12px var(--surface-shadow-far); --surface-shadow-7: 0 0 0 1px oklch(0 0 0 / 0.16), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far), 0 24px 24px -12px var(--surface-shadow-far), 0 48px 48px -24px var(--surface-shadow-ambient); --surface-shadow-8: 0 0 0 1px oklch(0 0 0 / 0.18), 0 1px 1px -0.5px var(--surface-shadow-near), 0 3px 3px -1.5px var(--surface-shadow-mid), 0 6px 6px -3px var(--surface-shadow-mid), 0 12px 12px -6px var(--surface-shadow-far), 0 24px 24px -12px var(--surface-shadow-far), 0 48px 48px -24px var(--surface-shadow-ambient), 0 96px 96px -48px var(--surface-shadow-ambient); --surface-rim-1: inset 0 0 0 1px var(--surface-ring-base); --surface-rim-2: inset 0 1px 0 0 var(--surface-hi-base), inset 0 0 0 1px var(--surface-ring-base); --surface-rim-3: inset 0 1px 0 0 var(--surface-hi-mid), inset 0 0 0 1px var(--surface-ring-base); --surface-rim-4: inset 0 1px 0 0 var(--surface-hi-mid), inset 0 0 0 1px var(--surface-ring-mid); --surface-rim-5: inset 0 1px 0 0 var(--surface-hi-high), inset 0 0 0 1px var(--surface-ring-mid); --surface-rim-6: inset 0 1px 0 0 var(--surface-hi-high), inset 0 0 0 1px var(--surface-ring-high); --surface-rim-7: inset 0 1px 0 0 var(--surface-hi-peak), inset 0 0 0 1px var(--surface-ring-high); --surface-rim-8: inset 0 1px 0 0 var(--surface-hi-peak), inset 0 0 0 1px var(--surface-ring-high); --surface-shadow-combined-1: var(--surface-shadow-1), var(--surface-rim-1); --surface-shadow-combined-2: var(--surface-shadow-2), var(--surface-rim-2); --surface-shadow-combined-3: var(--surface-shadow-3), var(--surface-rim-3); --surface-shadow-combined-4: var(--surface-shadow-4), var(--surface-rim-4); --surface-shadow-combined-5: var(--surface-shadow-5), var(--surface-rim-5); --surface-shadow-combined-6: var(--surface-shadow-6), var(--surface-rim-6); --surface-shadow-combined-7: var(--surface-shadow-7), var(--surface-rim-7); --surface-shadow-combined-8: var(--surface-shadow-8), var(--surface-rim-8); --chrome-shadow: 0 0 0 1px oklch(0 0 0 / 0.7), inset 0 1px 0 0 oklch(1 0 0 / 0.065); } @theme inline { /* Colors */ --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-neutral: var(--neutral); --color-neutral-foreground: var(--neutral-foreground); --color-emphasis: var(--emphasis); --color-emphasis-foreground: var(--emphasis-foreground); --color-chrome: var(--chrome); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-input-hover: var(--input-hover); --color-control-track: var(--control-track); --color-code-panel: var(--code-panel); --color-ring: var(--ring); --color-scrollbar: var(--scrollbar); /* Sidebar colors */ --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); /* Status colors */ --color-danger: var(--danger); --color-danger-foreground: var(--danger-foreground); --color-danger-border: var(--danger-border); --color-warning: var(--warning); --color-warning-foreground: var(--warning-foreground); --color-warning-border: var(--warning-border); --color-info: var(--info); --color-info-foreground: var(--info-foreground); --color-info-border: var(--info-border); --color-success: var(--success); --color-success-foreground: var(--success-foreground); --color-success-border: var(--success-border); /* Radius */ --radius-xs: calc(var(--radius) - 6px); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 2px); --radius-2xl: calc(var(--radius) + 4px); --radius-3xl: calc(var(--radius) + 6px); --radius-4xl: calc(var(--radius) + 8px); /* Easings */ --ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19); --ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1); --ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1); --ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1); /* Inset shadows for bordered elements (applied to before pseudo-element inset by 1px) */ --shadow-inset: 0 1px 2px 0 oklch(0 0 0 / 0.08); --shadow-inset-highlight: 0 -1px oklch(1 0 0 / 0.04); --color-surface-1: var(--surface-1); --color-surface-2: var(--surface-2); --color-surface-3: var(--surface-3); --color-surface-4: var(--surface-4); --color-surface-5: var(--surface-5); --color-surface-6: var(--surface-6); --color-surface-7: var(--surface-7); --color-surface-8: var(--surface-8); /* Generates bg-surface-hover / -active / -selected. */ --color-surface-hover: var(--surface-hover); --color-surface-active: var(--surface-active); --color-surface-selected: var(--surface-selected); } /* Base UI requirements, plus the chrome-surface contract. */ @layer base { body { position: relative; } .root { isolation: isolate; } } [data-surface="chrome"] { --foreground: var(--chrome-foreground); --muted-foreground: var(--chrome-muted-foreground); --border: var(--chrome-border); --muted: var(--chrome-hover); --accent: var(--chrome-hover); --accent-foreground: var(--chrome-foreground); --input: var(--chrome-hover); --surface-hover: var(--chrome-hover); --surface-active: var(--chrome-hover); --control-off: color-mix(in oklab, var(--chrome-foreground) 22%, transparent); --control-off-hover: color-mix( in oklab, var(--chrome-foreground) 28%, transparent ); --control-track: color-mix( in oklab, var(--chrome-foreground) 12%, transparent ); } ``` {/* THEME_CSS:END */} ## Extended Color Variables [#extended-color-variables] Cubby UI extends the standard shadcn/ui palette with additional tokens: * `--neutral` / `--neutral-foreground`: Neutral button variant * `--danger` / `--danger-foreground` / `--danger-border`: Error states with background tint * `--warning` / `--warning-foreground` / `--warning-border`: Warning states * `--info` / `--info-foreground` / `--info-border`: Informational states * `--success` / `--success-foreground` / `--success-border`: Success states * `--sidebar-*`: Dedicated sidebar color tokens * `--scrollbar`: Scrollbar thumb color ## Brand-hue tint controls [#brand-hue-tint-controls] Three tokens at the top of `:root` control the warmth of the entire neutral palette: * `--neutral-hue`: hue angle (0–360). The default `270` is a cool violet. * `--neutral-chroma`: chroma for mid-tone neutrals. The default `0.004` is barely tinted. * `--neutral-chroma-low`: chroma for near-extreme L (very light / very dark). Kept lower because high chroma at the extremes looks garish. Change these to retune the whole stack. Every neutral surface, foreground, and sidebar color references them. Try `--neutral-hue: 220` for a cooler blue tilt or `30` for a warm tan tilt. ## Surface elevation system [#surface-elevation-system] Cubby UI ships with an 8-level surface elevation ladder used by Cards, Popovers, Dialogs, Sheets, Drawers, and more. Each level has paired tokens: * `--surface-1` through `--surface-8`: Substrate colors (light: pure neutrals; dark: progressive lightness with brand tint) * `--surface-shadow-1` through `--surface-shadow-8`: Drop-shadow recipes (composed from `--surface-shadow-ring/near/mid/far/ambient`) * `--surface-rim-1` through `--surface-rim-8`: Inset highlight + ring (no-op in light, painted in dark) State overlays raise an underlying surface by a fixed perceptual delta: * `--surface-hover`: hover state overlay * `--surface-active`: pressed state overlay * `--surface-selected`: selected state overlay * `--surface-rim-color`: single-edge rim color (used by viewport-flush Sheet/Drawer) For the full elevation system docs, see [Surfaces](/docs/getting-started/surfaces). ## Form-field tokens [#form-field-tokens] Form fields and on/off controls use these dedicated tokens: * `--input`: the translucent field fill, which reads on the page, a Card, or a popup * `--input-hover`: the deeper tint for hovered and open select and date-picker triggers * `--outline` / `--outline-border`: the fill and edge of the `outline` field variant and the outline Button * `--control-off` / `--control-off-hover`: the resting grey of the Switch track and the Checkbox and Radio ring * `--control-track`: the empty part of Slider, Circular Slider, Progress and Meter * `--emphasis` / `--emphasis-foreground`: the neutral on-state fill of Checkbox, Radio, Switch, Slider, Circular Slider, Tabs and Toggle Group See the [Forms](/docs/getting-started/forms) guide for when to use the `outline` variant. ## Base UI Requirements [#base-ui-requirements] The `@layer base` section includes styles required for [Base UI](https://base-ui.com) components to work correctly: * **`body { position: relative }`** - Fixes backdrop positioning issues on iOS Safari for dialog and popover components. * **`.root { isolation: isolate }`** - Creates a stacking context for Base UI's portal-based components (dialogs, popovers, tooltips). Apply the `root` class to your application wrapper element as shown in the [Installation](/docs/getting-started/installation) guide. ## Dark Mode [#dark-mode] For dark mode setup, see the [shadcn/ui Dark Mode guide](https://ui.shadcn.com/docs/dark-mode). # useControllableState (/docs/hooks/use-controllable-state) ## Installation [#installation] ### Installation **CLI:** ```bash npx shadcn@latest add @cubby-ui/use-controllable-state ``` **Manual:** Copy the component source code to your project: Create `hooks/cubby-ui/use-controllable-state.ts`: ```tsx "use client"; import * as React from "react"; interface UseControllableStateParams { /** Controlled value. When defined, the hook is in controlled mode. */ value?: T; /** Initial value for uncontrolled mode. */ defaultValue: T; onValueChange?: (value: T) => void; } /** * Merges controlled and uncontrolled state into a single `[value, setValue]` * tuple, mirroring the pattern Radix and Base UI use internally. * * `onValueChange` fires synchronously in both modes. Functional updates are * safe in both modes: they resolve against an eagerly-advanced ref, so * multiple `setValue` calls in one event tick compose. `setValue` is * referentially stable across renders. * * The ref re-syncs from the prop at the next commit — but only if one happens. * Known constraint: a controlled parent that ignores `onValueChange` AND never * re-renders leaves the ref advanced past the real value, and a repeat * `setValue` to that same value early-returns at the `Object.is` check without * re-notifying. Acceptable — a controlled parent that never commits is already * outside the controlled contract — but it is the first thing to check when a * controlled value appears stuck. */ export function useControllableState({ value, defaultValue, onValueChange, }: UseControllableStateParams): [T, (next: T | ((prev: T) => T)) => void] { const isControlled = value !== undefined; const [uncontrolled, setUncontrolled] = React.useState(defaultValue); const current = isControlled ? (value as T) : uncontrolled; const onChangeRef = React.useRef(onValueChange); // Mirrors `current`. Re-synced after every commit; eagerly advanced by // setValue so multiple calls in one event tick compose. const currentRef = React.useRef(current); React.useEffect(() => { onChangeRef.current = onValueChange; currentRef.current = current; }); const setValue = React.useCallback( (next: T | ((prev: T) => T)) => { const prev = currentRef.current; const resolved = typeof next === "function" ? (next as (prev: T) => T)(prev) : next; if (Object.is(resolved, prev)) return; if (isControlled) { // The parent owns the state, but advance the ref eagerly so a second // functional setValue in the same tick composes on this one instead of // clobbering it. The ref re-syncs from the prop at the next commit, // which also corrects it if the parent rejected the change. currentRef.current = resolved; onChangeRef.current?.(resolved); } else { currentRef.current = resolved; setUncontrolled(resolved); onChangeRef.current?.(resolved); } }, [isControlled], ); return [current, setValue]; } ``` ## Overview [#overview] `useControllableState` merges controlled and uncontrolled state into a single `[value, setValue]` tuple, the pattern Radix and Base UI use internally. Build components that accept `value` + `onValueChange` for controlled use, or `defaultValue` for uncontrolled use, without branching the component logic. `onValueChange` fires synchronously during the event in both modes, once per accepted update. Functional updates are safe in both modes: uncontrolled updates resolve against an eagerly-advanced internal ref, so multiple `setValue` calls in one event tick compose instead of clobbering each other; controlled updates resolve against the last-committed value, since the parent owns the state. `setValue` is referentially stable across renders, so it is safe in dependency arrays and memoized contexts. ## Usage [#usage] ```tsx import { useControllableState } from "@/hooks/cubby-ui/use-controllable-state"; function Stepper({ value, defaultValue = 0, onValueChange, }: { value?: number; defaultValue?: number; onValueChange?: (value: number) => void; }) { const [count, setCount] = useControllableState({ value, defaultValue, onValueChange, }); return ; } ``` ## API Reference [#api-reference] ### Options [#options] - **value** (type: `T`) Controlled value. When defined, the hook is in controlled mode and state changes only flow through `onValueChange`. - **defaultValue** (type: `T`) Initial value for uncontrolled mode. - **onValueChange** void"> Called synchronously with the resolved value whenever it changes, in both modes. ### Returns [#returns] | Property | Type | Description | | ---------- | --------------------------------------- | ------------------------------------------------ | | `value` | `T` | The current value (controlled prop or internal). | | `setValue` | `(next: T \| ((prev: T) => T)) => void` | Updates the value; accepts functional updates. | # useFuzzyFilter (/docs/hooks/use-fuzzy-filter) ## Installation [#installation] ### Installation **CLI:** ```bash npx shadcn@latest add @cubby-ui/use-fuzzy-filter ``` **Manual:** 1. Install dependencies: ```bash npm install match-sorter ``` 2. Copy the component source code to your project: Create `hooks/cubby-ui/use-fuzzy-filter.ts`: ```tsx import { matchSorter } from "match-sorter"; type Ranking = (typeof matchSorter.rankings)[keyof typeof matchSorter.rankings]; export type FuzzyThreshold = | "case-sensitive-equal" | "equal" | "starts-with" | "word-starts-with" | "contains" | "acronym" | "matches"; const thresholdMap: Record = { "case-sensitive-equal": matchSorter.rankings.CASE_SENSITIVE_EQUAL, "equal": matchSorter.rankings.EQUAL, "starts-with": matchSorter.rankings.STARTS_WITH, "word-starts-with": matchSorter.rankings.WORD_STARTS_WITH, "contains": matchSorter.rankings.CONTAINS, "acronym": matchSorter.rankings.ACRONYM, "matches": matchSorter.rankings.MATCHES, }; function resolveThreshold(threshold: FuzzyThreshold | undefined): Ranking | undefined { if (!threshold) return undefined; return thresholdMap[threshold]; } export interface UseFuzzyFilterOptions { keys: Array; threshold?: FuzzyThreshold; } export function useFuzzyFilter(options: UseFuzzyFilterOptions) { const resolvedThreshold = resolveThreshold(options.threshold); const resolvedKeys = options.keys.map((key) => typeof key === "string" ? key : { key: key.key, threshold: resolveThreshold(key.threshold) }, ); const filter = (items: T[], query: string): T[] => { if (!query) { return items; } return matchSorter(items, query, { keys: resolvedKeys, threshold: resolvedThreshold, }); }; const filterItem = (item: T, query: string): boolean => { if (!query) { return true; } const results = matchSorter([item], query, { keys: resolvedKeys, threshold: resolvedThreshold, }); return results.length > 0; }; return { filter, filterItem }; } ``` ## Overview [#overview] `useFuzzyFilter` provides fuzzy matching capabilities using [match-sorter](https://github.com/kentcdodds/match-sorter). It returns filter functions that work with any list component, including Command and Autocomplete. **When to use:** * Search across multiple fields (label, description, tags) * Relevance-sorted results (best matches first) * Flexible matching (acronyms, partial matches, typo tolerance) ## Usage [#usage] ```tsx import { useFuzzyFilter } from "@/hooks/cubby-ui/use-fuzzy-filter"; const { filter, filterItem } = useFuzzyFilter({ keys: ["label", "description"], threshold: "contains", }); // Filter and sort an array const results = filter(items, query); // Check if single item matches const matches = filterItem(item, query); ``` ## Example with Command [#example-with-command] ```tsx import { useFuzzyFilter } from "@/hooks/cubby-ui/use-fuzzy-filter"; import { Command, CommandList, CommandItem } from "@/lib/cubby-ui/command"; function FuzzyCommand({ items }) { const [query, setQuery] = useState(""); const { filter } = useFuzzyFilter({ keys: ["label", "description"], }); // Filter and sort by relevance const filteredItems = filter(items, query); return ( {filteredItems.map((item) => ( {item.label} ))} ); } ``` ## API Reference [#api-reference] ### Options [#options] - **keys** "> Properties to search on. Can specify per-key thresholds for fine-grained control. ```tsx // Simple keys keys: ["label", "description"] // With per-key thresholds keys: [ { key: "label", threshold: "starts-with" }, { key: "description", threshold: "contains" }, ] ``` - **threshold** (type: `FuzzyThreshold`) - Default: `"matches"` Minimum ranking for a match. Values from strictest to loosest: * `"case-sensitive-equal"` - Exact match, case sensitive * `"equal"` - Exact match, case insensitive * `"starts-with"` - Query at start of string * `"word-starts-with"` - Query at start of any word * `"contains"` - Query anywhere in string * `"acronym"` - Matches acronyms (e.g., "rhl" matches "React Hooks Library") * `"matches"` - Very fuzzy matching (default) ### Return Value [#return-value] | Property | Type | Description | | ------------ | ------------------------------------- | ------------------------------------------------------------------- | | `filter` | `(items: T[], query: string) => T[]` | Filters and sorts an array of items by relevance | | `filterItem` | `(item: T, query: string) => boolean` | Checks if a single item matches. Use with component's `filter` prop | ## Filtering Patterns [#filtering-patterns] ### External filtering with relevance sorting [#external-filtering-with-relevance-sorting] Use `filter` when you want results sorted by relevance: ```tsx const { filter } = useFuzzyFilter({ keys: ["label"] }); const [query, setQuery] = useState(""); const filteredItems = filter(items, query); ... ``` ### Internal filtering (boolean check) [#internal-filtering-boolean-check] Use `filterItem` for simple boolean filtering without relevance sorting: ```tsx const { filterItem } = useFuzzyFilter({ keys: ["label"] }); ... ``` ## Used By [#used-by] * [Command](/docs/components/command) - For fuzzy search in command menus * [Autocomplete](/docs/components/autocomplete) - For fuzzy search in suggestions # useListVirtualizer (/docs/hooks/use-list-virtualizer) ## Installation [#installation] ### Installation **CLI:** ```bash npx shadcn@latest add @cubby-ui/use-list-virtualizer ``` **Manual:** 1. Install dependencies: ```bash npm install @tanstack/react-virtual ``` 2. Copy the component source code to your project: Create `hooks/cubby-ui/use-list-virtualizer.ts`: ```tsx import * as React from "react"; import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; /** * The virtualizer instance type. Use to type a ref that receives * the virtualizer via the `virtualizerRef` option. */ export type ListVirtualizerInstance = ReturnType< typeof useVirtualizer >; /** * Returns a stable `onItemHighlighted` handler that scrolls the virtualizer * to the highlighted item on keyboard navigation. * * Use this on the Root component when the virtualizer lives in a child * component (e.g. when using `useFilteredItems`). */ export function useHighlightHandler( virtualizerRef: React.RefObject, ) { return React.useCallback( ( item: unknown, { reason, index, }: { reason: "none" | "keyboard" | "pointer"; index: number }, ) => { const virtualizer = virtualizerRef.current; if (!item || !virtualizer) return; const isStart = index === 0; const isEnd = index === virtualizer.options.count - 1; const shouldScroll = reason === "none" || (reason === "keyboard" && (isStart || isEnd)); if (shouldScroll) { queueMicrotask(() => { virtualizer.scrollToIndex(index, { align: isEnd ? "start" : "end", }); }); } }, // virtualizerRef is a stable ref object // eslint-disable-next-line react-hooks/exhaustive-deps [], ); } export interface UseListVirtualizerOptions { /** * Whether virtualization is enabled. * Typically tied to whether the list container is open/visible. * @default true */ enabled?: boolean; /** * All items in the list (unfiltered). * Used by Base UI Autocomplete for proper aria-setsize. */ items: T[]; /** * The filtered items to display. * Can be the same as `items` if no filtering is applied (e.g., server-side filtering). */ filteredItems: T[]; /** * Estimated height of each item in pixels, or a function that returns * the estimate for each item. Used for initial layout before measurement. * @default 40 */ estimateSize?: number | ((index: number, item: T) => number); /** * Number of items to render outside the visible area. * Higher values = smoother scrolling, more memory. * @default 20 */ overscan?: number; /** * Padding at the start of the list in pixels. * Should match the list container's padding. * @default 8 */ paddingStart?: number; /** * Padding at the end of the list in pixels. * @default 8 */ paddingEnd?: number; /** * Ref to expose the virtualizer instance to a parent component. * When provided, the virtualizer is exposed via `useImperativeHandle`. * Use with `createHighlightHandler` for the `useFilteredItems` pattern. */ virtualizerRef?: React.RefObject; } export interface UseListVirtualizerReturn { /** * Props to spread on the root component (Autocomplete.Root). * Includes: virtualized, items, filteredItems, onItemHighlighted */ rootProps: { virtualized: true; items: T[]; filteredItems: T[]; onItemHighlighted: ( item: T | null | undefined, details: { reason: "none" | "keyboard" | "pointer"; index: number } ) => void; }; /** * Ref callback for the scroll container element. * Attach to a div wrapping the virtual items. */ scrollRef: (element: HTMLDivElement | null) => void; /** * Ref callback for measuring individual items. * Attach to each list item for dynamic height support. */ measureRef: (element: HTMLDivElement | null) => void; /** * Total height of all virtual items in pixels. * Use for the height of the placeholder container. */ totalSize: number; /** * Array of virtual items to render. * Each contains: key, index, start, size */ virtualItems: VirtualItem[]; /** * Get the item at a virtual index. * Convenience method: filteredItems[virtualItem.index] */ getItem: (virtualItem: { index: number }) => T | undefined; /** * Get inline styles for a virtual item. * Returns: { position, top, left, right, transform } */ getItemStyle: (virtualItem: { start: number }) => React.CSSProperties; /** * Get aria attributes and props for a virtual item. * Returns: { 'aria-setsize', 'aria-posinset', 'data-index', index } */ getItemProps: (virtualItem: { index: number }) => { "aria-setsize": number; "aria-posinset": number; "data-index": number; index: number; }; } export function useListVirtualizer( options: UseListVirtualizerOptions ): UseListVirtualizerReturn { const { enabled: _enabled = true, items, filteredItems, estimateSize = 40, overscan = 20, paddingStart = 8, paddingEnd = 8, virtualizerRef, } = options; const scrollElementRef = React.useRef(null); const virtualizer = useVirtualizer({ // Don't pass `enabled` to virtualizer - let it always calculate sizes // to prevent height jumping during close animation count: filteredItems.length, getScrollElement: () => scrollElementRef.current, estimateSize: typeof estimateSize === "function" ? (index) => estimateSize(index, filteredItems[index]) : () => estimateSize, overscan, paddingStart, paddingEnd, scrollPaddingStart: paddingStart, scrollPaddingEnd: paddingEnd, }); // Expose virtualizer to parent when ref is provided React.useImperativeHandle(virtualizerRef, () => virtualizer, [virtualizer]); // Ref callback that stores the element and triggers measurement const scrollRef = React.useCallback( (element: HTMLDivElement | null) => { scrollElementRef.current = element; if (element) { virtualizer.measure(); } }, [virtualizer] ); // Handler for keyboard/pointer navigation scroll-to-index const onItemHighlighted = React.useCallback( ( item: T | null | undefined, { reason, index, }: { reason: "none" | "keyboard" | "pointer"; index: number } ) => { if (item == null) return; const isStart = index === 0; const isEnd = index === filteredItems.length - 1; // Scroll on initial highlight or keyboard navigation at edges const shouldScroll = reason === "none" || (reason === "keyboard" && (isStart || isEnd)); if (shouldScroll) { queueMicrotask(() => { virtualizer.scrollToIndex(index, { align: isEnd ? "start" : "end", }); }); } }, [filteredItems.length, virtualizer] ); const getItem = React.useCallback( (virtualItem: { index: number }) => filteredItems[virtualItem.index], [filteredItems] ); const getItemStyle = React.useCallback( (virtualItem: { start: number }): React.CSSProperties => ({ position: "absolute", top: 0, left: 0, right: 0, // Use right: 0 instead of width: 100% so margins work correctly // No explicit height - let content determine size for dynamic measurement transform: `translateY(${virtualItem.start}px)`, }), [] ); const getItemProps = React.useCallback( (virtualItem: { index: number }) => ({ "aria-setsize": filteredItems.length, "aria-posinset": virtualItem.index + 1, "data-index": virtualItem.index, index: virtualItem.index, }), [filteredItems.length] ); return { rootProps: { virtualized: true, items, filteredItems, onItemHighlighted, }, scrollRef, measureRef: virtualizer.measureElement as ( element: HTMLDivElement | null ) => void, totalSize: filteredItems.length > 0 ? virtualizer.getTotalSize() : 0, virtualItems: virtualizer.getVirtualItems(), getItem, getItemStyle, getItemProps, }; } ``` ## Overview [#overview] `useListVirtualizer` provides virtualization for large lists using [TanStack Virtual](https://tanstack.com/virtual). It handles scroll positioning, item measurement, and keyboard navigation, making it easy to render thousands of items without performance issues. **When to use:** * Lists with 100+ items * Command menus, autocompletes, or selects with large datasets * Any scrollable list where DOM node count impacts performance ## Usage [#usage] ```tsx import { useListVirtualizer } from "@/hooks/cubby-ui/use-list-virtualizer"; const { rootProps, // Spread on root component scrollRef, // Attach to scroll container measureRef, // Attach to each item for dynamic heights totalSize, // Total height for virtual container virtualItems, // Array to map over getItem, // Get data for virtual item getItemStyle, // Get positioning styles getItemProps, // Get aria attributes } = useListVirtualizer({ items, // All items (for aria-setsize) filteredItems, // Items to display estimateSize: 40, // Item height in px }); ``` ## Example with Command [#example-with-command] ```tsx import { useListVirtualizer } from "@/hooks/cubby-ui/use-list-virtualizer"; import { Command, CommandVirtualizedList, CommandItem, } from "@/lib/cubby-ui/command"; // Helper to extract searchable text from items const getItemLabel = (item: Item | null) => item?.label ?? ""; function VirtualizedCommand({ items, filteredItems }) { const { rootProps, scrollRef, measureRef, totalSize, virtualItems, getItem, getItemStyle, getItemProps, } = useListVirtualizer({ items, filteredItems, estimateSize: 40, }); return ( {virtualItems.map((virtualItem) => { const item = getItem(virtualItem); return ( {item.label} ); })} ); } ``` ## Usage with useFilteredItems [#usage-with-usefiltereditems] When using Base UI's `useFilteredItems()` hook, filtered items come from inside the Root component. This requires a child component pattern with `virtualizerRef` + `useHighlightHandler`: ```tsx import { AutocompleteRoot, AutocompleteVirtualizedList, useAutocompleteFilteredItems, } from "@/components/ui/cubby-ui/autocomplete"; import { useListVirtualizer, useHighlightHandler, type ListVirtualizerInstance, } from "@/hooks/cubby-ui/use-list-virtualizer"; function MyAutocomplete({ items }) { const virtualizerRef = React.useRef(null); const onItemHighlighted = useHighlightHandler(virtualizerRef); return ( {/* Input, positioner, etc. */} ); } function VirtualizedContent({ items, virtualizerRef }) { const filteredItems = useAutocompleteFilteredItems(); const { scrollRef, measureRef, totalSize, virtualItems, getItem, getItemStyle, getItemProps } = useListVirtualizer({ items, filteredItems, virtualizerRef }); return ( {virtualItems.map((vi) => { /* ... */ })} ); } ``` This eliminates manual query state, `useDeferredValue`, `useFilter()`, and `useMemo` filtering. The Root handles filtering internally, and `useFilteredItems()` provides the result to the virtualizer. ## Performance Tips [#performance-tips] For optimal scroll performance with large lists: 1. **Use `useDeferredValue` for search queries** - Prevents filtering from blocking scroll rendering. Use the resolved pattern to avoid stale results when clearing: ```tsx const deferredQuery = React.useDeferredValue(query); // Use immediate value when empty to avoid stale results const resolvedQuery = query === "" || deferredQuery === "" ? query : deferredQuery; const filteredItems = React.useMemo( () => items.filter((item) => contains(item, resolvedQuery, getLabel)), [contains, resolvedQuery], ); ``` 2. **Use `nativeScroll` on `CommandVirtualizedList`** - Avoids custom scroll container overhead for smoother fast scrolling. ## API Reference [#api-reference] ### Options [#options] - **items** (type: `T[]`) All items in the list (unfiltered). Used for proper `aria-setsize` accessibility attribute. - **filteredItems** (type: `T[]`) The filtered items to display. Can equal `items` for server-side filtering. - **enabled** (type: `boolean`) - Default: `true` Whether virtualization is active. Typically tied to dialog/popover open state. - **estimateSize** number)" defaultValue="40"> Estimated height of each item in pixels. Use a function for variable heights based on item content. - **overscan** (type: `number`) - Default: `20` Number of items to render outside the visible area. Higher values improve scroll smoothness but use more memory. - **paddingStart** (type: `number`) - Default: `8` Padding at the start of the list in pixels. Should match your list container's padding. - **paddingEnd** (type: `number`) - Default: `8` Padding at the end of the list in pixels. - **virtualizerRef** "> Ref to expose the virtualizer instance to a parent component via `useImperativeHandle`. Use with `useHighlightHandler` for the `useFilteredItems` pattern. ### useHighlightHandler [#usehighlighthandler] ```ts function useHighlightHandler( virtualizerRef: React.RefObject, ): (item: unknown, details: { reason: string; index: number }) => void; ``` Returns a stable `onItemHighlighted` callback that scrolls the virtualizer to the highlighted item on keyboard navigation. Use this on the Root component when the virtualizer lives in a child component. ### ListVirtualizerInstance [#listvirtualizerinstance] ```ts type ListVirtualizerInstance = ReturnType; ``` Type alias for the virtualizer instance. Use to type the `virtualizerRef`. ### Return Value [#return-value] | Property | Type | Description | | -------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------- | | `rootProps` | `object` | Spread on root component. Includes `virtualized`, `items`, `filteredItems`, `onItemHighlighted` | | `scrollRef` | `(el: HTMLDivElement \| null) => void` | Ref callback for the scroll container | | `measureRef` | `(el: HTMLDivElement \| null) => void` | Ref callback for each item (enables dynamic heights) | | `totalSize` | `number` | Total height of all virtual items in pixels | | `virtualItems` | `VirtualItem[]` | Array of virtual items to render | | `getItem` | `(virtualItem) => T \| undefined` | Get the data item for a virtual item | | `getItemStyle` | `(virtualItem) => CSSProperties` | Get positioning styles for a virtual item | | `getItemProps` | `(virtualItem) => object` | Get aria attributes (`aria-setsize`, `aria-posinset`, `data-index`, `index`) | ## Used By [#used-by] * [Command](/docs/components/command) - For virtualized command menus * [Autocomplete](/docs/components/autocomplete) - For virtualized autocomplete lists * [Combobox](/docs/components/combobox) - For virtualized combobox lists # highlightText (/docs/utils/highlight-text) ## Installation [#installation] ### Installation **CLI:** ```bash npx shadcn@latest add @cubby-ui/highlight-text ``` **Manual:** Copy the component source code to your project: Create `lib/cubby-ui/highlight-text.tsx`: ```tsx import * as React from "react"; export function highlightText(text: string, query: string): React.ReactNode { const trimmed = query.trim(); if (!trimmed) { return text; } const limited = trimmed.slice(0, 100); const escaped = limited.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(`(${escaped})`, "gi"); return text.split(regex).map((part, idx) => part.toLowerCase() === limited.toLowerCase() ? ( {part} ) : ( part ), ); } ``` ## Overview [#overview] `highlightText` wraps matching portions of text in styled `` elements. Use it to show which parts of a search result match the user's query, in command menus, autocompletes, or any filtered list. ## Usage [#usage] ```tsx import { highlightText } from "@/lib/cubby-ui/highlight-text"; // Returns ReactNode with matched portions wrapped in const highlighted = highlightText("React Hooks Library", "hook"); // Result: React Hooks Library ``` ## Example with Command [#example-with-command] ```tsx import { highlightText } from "@/lib/cubby-ui/highlight-text"; import { Command, CommandItem } from "@/lib/cubby-ui/command"; function SearchResults({ items, query }) { return ( {items.map((item) => ( {highlightText(item.label, query)} ))} ); } ``` ## API Reference [#api-reference] ```ts function highlightText(text: string, query: string): ReactNode ``` ### Parameters [#parameters] - **text** (type: `string`) The text to search within and highlight matches. - **query** (type: `string`) The search query to match against. Matching is case-insensitive. ### Returns [#returns] `ReactNode` - The original text with matched portions wrapped in styled `` elements. ### Styling [#styling] Matched text is wrapped in `` elements with the following default styles: ```tsx {matchedText} ``` Matches render bold, with no background, in `--primary-soft-foreground`: a darker mix of the primary that stays legible as text. ## Implementation Details [#implementation-details] * **Case-insensitive matching**: Query "hook" matches "Hook", "HOOK", etc. * **Safe regex handling**: Special regex characters in the query are escaped * **Query length limit**: Queries are limited to 100 characters for performance * **Empty query handling**: Returns the original text unchanged if query is empty or whitespace-only ## Used By [#used-by] * [Command](/docs/components/command) - For highlighting search matches * [Autocomplete](/docs/components/autocomplete) - For highlighting suggestion matches # Scroll Fade (/docs/utils/scroll-fade) `scroll-fade` adds a scroll-aware edge fade to any element that scrolls (a code block, a table wrapper, a third-party list) without wrapping it in a component. It masks the content itself, so the fade dissolves over any background. The effect is driven by [CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations) with no JavaScript, and falls back to a static fade where they aren't supported. It's the standalone version of the fade built into [ScrollArea](/docs/components/scroll-area)'s `nativeScroll` mode: same animation, same fallback. *Directly inspired by shadcn's [`scroll-fade`](https://ui.shadcn.com/docs/utils/scroll-fade) utility, adapted for Cubby UI with a couple of refinements: a fixed-pixel reveal clamped to the scroll range so short scrollers still fully reveal.* ```tsx // scroll-fade-demo.tsx export default function ScrollFadeDemo() { return (

The scroll-fade utility works on any scroll container — no component required. Add it to the element that has{" "} overflow-y-auto and the edges dissolve as you scroll.

It is driven by CSS scroll-driven animations, so the fade tracks the scroll position with no JavaScript. At the top only the bottom edge fades; mid-scroll both edges soften; at the end the bottom sharpens.

Because it masks the content itself, it adapts to any background without configuration. Use scroll-fade-x for horizontal scrollers and scroll-fade-none to switch it off responsively.

In browsers without scroll-driven animation support, it falls back to a static fade on both edges.

); } ``` ## Installation [#installation] The utility relies on `@property` to animate the mask, which can't be injected through the registry's CSS channel, so it ships as a CSS file. Add it, then import it once in your global CSS: ### Installation **CLI:** ```bash npx shadcn@latest add @cubby-ui/scroll-fade ``` **Manual:** Copy the component source code to your project: Create `components/ui/cubby-ui/scroll-fade.css`: ```tsx /* ------------------------------------------------------------------------------------------------- * Scroll Fade utility * * Standalone, component-free edge fade for any scroll container. Add * `scroll-fade` (or an axis/edge variant) to the element that has `overflow` * and the edges dissolve as you scroll — masking the content so it works over * any background. Scroll-aware via CSS scroll-driven animations; falls back to * a static fade where unsupported. No JS, no scroll listeners. * * Import this file once in your global CSS (after `@import "tailwindcss"`): * @import "./components/ui/cubby-ui/scroll-fade.css"; * * --scroll-fade-size: fade depth (default min(12%, 2.5rem)) * --scroll-fade-reveal: scroll distance the fade eases over (default 6rem), * clamped to the scroll range so short scrollers still * fully reveal. * * The @property + @keyframes here are byte-identical to the ScrollArea * component's scroll-area.css so the two never conflict when both are present. * -------------------------------------------------------------------------------------------------*/ @property --scroll-fade-top { syntax: ""; inherits: false; initial-value: 0px; } @property --scroll-fade-bottom { syntax: ""; inherits: false; initial-value: 0px; } @property --scroll-fade-left { syntax: ""; inherits: false; initial-value: 0px; } @property --scroll-fade-right { syntax: ""; inherits: false; initial-value: 0px; } @keyframes scroll-fade-reveal-top { from { --scroll-fade-top: 0px; } to { --scroll-fade-top: var(--scroll-fade-size); } } @keyframes scroll-fade-reveal-bottom { from { --scroll-fade-bottom: var(--scroll-fade-size); } to { --scroll-fade-bottom: 0px; } } @keyframes scroll-fade-reveal-left { from { --scroll-fade-left: 0px; } to { --scroll-fade-left: var(--scroll-fade-size); } } @keyframes scroll-fade-reveal-right { from { --scroll-fade-right: var(--scroll-fade-size); } to { --scroll-fade-right: 0px; } } /* Vertical fade (default). `scroll-fade` and `scroll-fade-y` are aliases. */ @utility scroll-fade { --scroll-fade-size: min(12%, 2.5rem); -webkit-mask-image: linear-gradient( to bottom, transparent 0, #000 var(--scroll-fade-top, 0px), #000 calc(100% - var(--scroll-fade-bottom, 0px)), transparent 100% ); mask-image: linear-gradient( to bottom, transparent 0, #000 var(--scroll-fade-top, 0px), #000 calc(100% - var(--scroll-fade-bottom, 0px)), transparent 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; @supports (animation-timeline: scroll()) { animation: scroll-fade-reveal-top 1ms ease-in-out, scroll-fade-reveal-bottom 1ms ease-in-out; animation-timeline: scroll(self y), scroll(self y); animation-range: 0 min(var(--scroll-fade-reveal, 6rem), 100%), calc(100% - min(var(--scroll-fade-reveal, 6rem), 100%)) 100%; animation-fill-mode: both; } @supports not (animation-timeline: scroll()) { --scroll-fade-top: var(--scroll-fade-size); --scroll-fade-bottom: var(--scroll-fade-size); } } @utility scroll-fade-y { --scroll-fade-size: min(12%, 2.5rem); -webkit-mask-image: linear-gradient( to bottom, transparent 0, #000 var(--scroll-fade-top, 0px), #000 calc(100% - var(--scroll-fade-bottom, 0px)), transparent 100% ); mask-image: linear-gradient( to bottom, transparent 0, #000 var(--scroll-fade-top, 0px), #000 calc(100% - var(--scroll-fade-bottom, 0px)), transparent 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; @supports (animation-timeline: scroll()) { animation: scroll-fade-reveal-top 1ms ease-in-out, scroll-fade-reveal-bottom 1ms ease-in-out; animation-timeline: scroll(self y), scroll(self y); animation-range: 0 min(var(--scroll-fade-reveal, 6rem), 100%), calc(100% - min(var(--scroll-fade-reveal, 6rem), 100%)) 100%; animation-fill-mode: both; } @supports not (animation-timeline: scroll()) { --scroll-fade-top: var(--scroll-fade-size); --scroll-fade-bottom: var(--scroll-fade-size); } } /* Horizontal fade. */ @utility scroll-fade-x { --scroll-fade-size: min(12%, 2.5rem); -webkit-mask-image: linear-gradient( to right, transparent 0, #000 var(--scroll-fade-left, 0px), #000 calc(100% - var(--scroll-fade-right, 0px)), transparent 100% ); mask-image: linear-gradient( to right, transparent 0, #000 var(--scroll-fade-left, 0px), #000 calc(100% - var(--scroll-fade-right, 0px)), transparent 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; @supports (animation-timeline: scroll()) { animation: scroll-fade-reveal-left 1ms ease-in-out, scroll-fade-reveal-right 1ms ease-in-out; animation-timeline: scroll(self x), scroll(self x); animation-range: 0 min(var(--scroll-fade-reveal, 6rem), 100%), calc(100% - min(var(--scroll-fade-reveal, 6rem), 100%)) 100%; animation-fill-mode: both; } @supports not (animation-timeline: scroll()) { --scroll-fade-left: var(--scroll-fade-size); --scroll-fade-right: var(--scroll-fade-size); } } /* Single-edge fades. */ @utility scroll-fade-t { --scroll-fade-size: min(12%, 2.5rem); -webkit-mask-image: linear-gradient( to bottom, transparent 0, #000 var(--scroll-fade-top, 0px), #000 100% ); mask-image: linear-gradient( to bottom, transparent 0, #000 var(--scroll-fade-top, 0px), #000 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; @supports (animation-timeline: scroll()) { animation: scroll-fade-reveal-top 1ms ease-in-out; animation-timeline: scroll(self y); animation-range: 0 min(var(--scroll-fade-reveal, 6rem), 100%); animation-fill-mode: both; } @supports not (animation-timeline: scroll()) { --scroll-fade-top: var(--scroll-fade-size); } } @utility scroll-fade-b { --scroll-fade-size: min(12%, 2.5rem); -webkit-mask-image: linear-gradient( to bottom, #000 0, #000 calc(100% - var(--scroll-fade-bottom, 0px)), transparent 100% ); mask-image: linear-gradient( to bottom, #000 0, #000 calc(100% - var(--scroll-fade-bottom, 0px)), transparent 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; @supports (animation-timeline: scroll()) { animation: scroll-fade-reveal-bottom 1ms ease-in-out; animation-timeline: scroll(self y); animation-range: calc(100% - min(var(--scroll-fade-reveal, 6rem), 100%)) 100%; animation-fill-mode: both; } @supports not (animation-timeline: scroll()) { --scroll-fade-bottom: var(--scroll-fade-size); } } @utility scroll-fade-l { --scroll-fade-size: min(12%, 2.5rem); -webkit-mask-image: linear-gradient( to right, transparent 0, #000 var(--scroll-fade-left, 0px), #000 100% ); mask-image: linear-gradient( to right, transparent 0, #000 var(--scroll-fade-left, 0px), #000 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; @supports (animation-timeline: scroll()) { animation: scroll-fade-reveal-left 1ms ease-in-out; animation-timeline: scroll(self x); animation-range: 0 min(var(--scroll-fade-reveal, 6rem), 100%); animation-fill-mode: both; } @supports not (animation-timeline: scroll()) { --scroll-fade-left: var(--scroll-fade-size); } } @utility scroll-fade-r { --scroll-fade-size: min(12%, 2.5rem); -webkit-mask-image: linear-gradient( to right, #000 0, #000 calc(100% - var(--scroll-fade-right, 0px)), transparent 100% ); mask-image: linear-gradient( to right, #000 0, #000 calc(100% - var(--scroll-fade-right, 0px)), transparent 100% ); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; @supports (animation-timeline: scroll()) { animation: scroll-fade-reveal-right 1ms ease-in-out; animation-timeline: scroll(self x); animation-range: calc(100% - min(var(--scroll-fade-reveal, 6rem), 100%)) 100%; animation-fill-mode: both; } @supports not (animation-timeline: scroll()) { --scroll-fade-right: var(--scroll-fade-size); } } /* Disable — works in any order, e.g. `md:scroll-fade-none`. */ @utility scroll-fade-none { -webkit-mask-image: none; mask-image: none; } ``` ```css title="app/globals.css" @import "tailwindcss"; /* adjust the relative path to wherever the CLI placed the file */ @import "../components/ui/cubby-ui/scroll-fade.css"; ``` If you already installed the [ScrollArea](/docs/components/scroll-area) component, this same animation logic is bundled in its `scroll-area.css`; the utility just exposes it as standalone classes. ## Usage [#usage] Add `scroll-fade` to the element that has `overflow`. It's scroll-aware: at the top only the bottom edge fades; mid-scroll both edges soften; at the end the bottom sharpens. ```tsx
{/* ... */}
{/* ... */}
``` If the content doesn't overflow, no fade is shown, so you can apply it to any list without checking whether it scrolls. ## Classes [#classes] | Class | Effect | | ------------------------------------ | -------------------------------------------------------- | | `scroll-fade` / `scroll-fade-y` | Fade top and bottom edges | | `scroll-fade-x` | Fade left and right edges | | `scroll-fade-t` / `-b` / `-l` / `-r` | Fade a single edge | | `scroll-fade-none` | Disable (works in any order, e.g. `md:scroll-fade-none`) | ## Customization [#customization] Two CSS variables control the look, the same ones the `ScrollArea` component uses: | Variable | Default | Description | | ---------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------- | | `--scroll-fade-size` | `min(12%, 2.5rem)` | Fade depth: 12% of the container, capped at `2.5rem`. | | `--scroll-fade-reveal` | `6rem` | Scroll distance the fade eases in/out over. Clamped to the scroll range so short scrollers still fully reveal. | Set them per instance with arbitrary properties: ```tsx
{/* ... */}
{/* ... */}
``` ## Fallback [#fallback] The scroll-aware behavior uses CSS scroll-driven animations, with no JavaScript and no scroll listeners. In browsers that don't support them, `scroll-fade` falls back to a static fade on both edges (and edge utilities to a static fade on the selected edge). Because the mask is applied to the scroll container itself, a visible scrollbar fades with the content at the edges. Hide it with `[scrollbar-width:none]` (or your preferred utility) if you want only the content to fade. ## Used By [#used-by] * [ScrollArea](/docs/components/scroll-area): the `nativeScroll` mode uses this fade internally. # Shimmer (/docs/utils/shimmer) `shimmer` paints text with an animated highlight that loops across it: the canonical "Generating response…" / "Thinking…" affordance for streaming or in-progress states. It's pure CSS: the text is rendered with `background-clip: text` and a gradient highlight sweeps over it in a seamless loop. The highlight is derived from the element's own `currentColor` via [relative color syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors), so it adapts to any text color with no configuration, and brightens automatically in dark mode. *Directly inspired by shadcn's [`shimmer`](https://ui.shadcn.com/docs/utils/shimmer) utility, adapted for Cubby UI and re-tuned to read well against its color tokens in light and dark.* ```tsx // shimmer-demo.tsx export default function ShimmerDemo() { return (

Generating response…

); } ``` > Because the highlight **brightens** the text, the effect reads best on dimmed text such as `text-muted-foreground`. There's little headroom to brighten text that's already near-white (e.g. `text-foreground` in dark mode). This matches the typical use: a muted status line. ## Installation [#installation] Like [Scroll Fade](/docs/utils/scroll-fade), shimmer relies on `@property` to animate the highlight, so it ships as a CSS file rather than through the registry's CSS channel. Add it, then import it once in your global CSS: ### Installation **CLI:** ```bash npx shadcn@latest add @cubby-ui/shimmer ``` **Manual:** Copy the component source code to your project: Create `components/ui/cubby-ui/shimmer.css`: ```tsx /* ------------------------------------------------------------------------------------------------- * Shimmer utility * * An animated highlight that sweeps across text — for live status / streaming * states ("Generating response…", "Thinking…"). The text is painted with * `background-clip: text` and a gradient highlight loops across it. * * Built on `currentColor` via relative color syntax, so the highlight is * derived from the element's own text color and adapts to any token (from * `text-muted-foreground` to a brand color) with no configuration. In dark * mode the highlight brightens automatically to stay visible. * * Import this file once in your global CSS (after `@import "tailwindcss"`): * @import "./components/ui/cubby-ui/shimmer.css"; * * --shimmer-color: highlight color (default: derived from currentColor) * --shimmer-duration: one sweep, in ms (default 2000) * --shimmer-spread: width of the highlight band (default calc(3ch + 40px)) * --shimmer-angle: tilt of the band in degrees (default 20) * * Note: distinct from the Skeleton component's box sweep (`.skeleton-shimmer`, * `@keyframes shimmer`). This is a text effect and uses `@keyframes tw-shimmer`. * -------------------------------------------------------------------------------------------------*/ @property --shimmer-angle { syntax: ""; inherits: true; initial-value: 20deg; } @property --shimmer-image { syntax: "*"; inherits: false; } @property --shimmer-text-fill { syntax: "*"; inherits: false; } @keyframes tw-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } } @utility shimmer { --_spread: var(--shimmer-spread, calc(3ch + 40px)); --_base: currentColor; --_highlight: var( --shimmer-color, oklch(from currentColor l c h / calc(alpha * 0.2)) ); background-image: var( --shimmer-image, linear-gradient( calc(90deg + var(--shimmer-angle)), var(--_base) calc(50% - var(--_spread)), color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% - var(--_spread) * 0.5), var(--_highlight) 50%, color-mix(in oklch, var(--_highlight), var(--_base) 50%) calc(50% + var(--_spread) * 0.5), var(--_base) calc(50% + var(--_spread)) ) ); background-repeat: no-repeat; background-size: calc(200% + var(--_spread) * 2) 100%; background-position: 0 0; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: var(--shimmer-text-fill, transparent); animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite; @variant dark { --_highlight: var( --shimmer-color, oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4)) ); } &:where([dir="rtl"], [dir="rtl"] *) { animation-direction: reverse; } } @utility shimmer-once { animation-iteration-count: 1; } @utility shimmer-reverse { animation-direction: reverse; } @utility shimmer-none { --shimmer-image: none; --shimmer-text-fill: currentColor; } @utility shimmer-color-* { --shimmer-color: --value(--color, [color]); --shimmer-color: color-mix( in oklch, --value(--color, [color]) calc(--modifier(integer) * 1%), transparent ); } @utility shimmer-duration-* { --shimmer-duration: calc(--value(integer) * 1ms); } @utility shimmer-spread-* { --shimmer-spread: calc(var(--spacing) * --value(integer)); --shimmer-spread: --value([length], [percentage]); } @utility shimmer-angle-* { --shimmer-angle: calc(--value(integer) * 1deg); } @media (prefers-reduced-motion: reduce) { .shimmer { animation: none; background-image: none; -webkit-text-fill-color: currentColor; } } ``` ```css title="app/globals.css" @import "tailwindcss"; /* adjust the relative path to wherever the CLI placed the file */ @import "../components/ui/cubby-ui/shimmer.css"; ``` ## Usage [#usage] Add `shimmer` to any text element. The highlight is built from the text color, so it works on `text-muted-foreground` or a brand color with no extra setup. ```tsx

Generating response…

``` It composes with any component that renders text, for example a status label inside a button or badge while an action is in flight. ## Classes [#classes] Every variant tab below has a **Code** tab with the exact markup. Reference for the full surface: | Class | Effect | | ----------------------- | ------------------------------------------------------------------------- | | `shimmer` | Sweep an animated highlight across the text | | `shimmer-once` | Play a single sweep instead of looping | | `shimmer-reverse` | Sweep in the opposite direction | | `shimmer-none` | Turn the effect off (works responsively, e.g. `md:shimmer-none`) | | `shimmer-color-` | Set the highlight color (accepts `/` and arbitrary values) | | `shimmer-duration-` | One sweep duration in milliseconds (default `2000`) | | `shimmer-spread-` | Width of the highlight band on the spacing scale (also arbitrary lengths) | | `shimmer-angle-` | Tilt of the highlight band in degrees (default `20`) | ## Color [#color] The highlight defaults to a derivative of the text color. Use `shimmer-color-` to set it explicitly. It accepts theme colors (with an optional `/` modifier) or any arbitrary value like `shimmer-color-[#22c55e]`. ```tsx // shimmer-color.tsx export default function ShimmerColor() { return (

Generating response…

Generating response…

Generating response…

); } ``` ## Duration [#duration] Use `shimmer-duration-` to set how long one sweep takes, in milliseconds. The default is `2000`. ```tsx // shimmer-duration.tsx export default function ShimmerDuration() { return (

Default · 2s

Faster · 1s

Slower · 3.5s

); } ``` ## Spread [#spread] Use `shimmer-spread-` to set the width of the highlight band on the spacing scale, or an arbitrary length like `shimmer-spread-[3rem]`. The default is `calc(3ch + 40px)`: a fixed base plus a `3ch` term that scales with the font size. ```tsx // shimmer-spread.tsx export default function ShimmerSpread() { return (

Default band

Wider band

Narrow band

); } ``` ## Angle [#angle] Use `shimmer-angle-` to set the tilt of the highlight band in degrees. The default is `20`. ```tsx // shimmer-angle.tsx export default function ShimmerAngle() { return (

Default · 20°

Flat · 0°

Steeper · 45°

); } ``` ## Play Once [#play-once] Use `shimmer-once` for a single sweep instead of a loop, useful as a reveal when streaming completes. Pair it with `shimmer-duration-` to control how long the reveal takes. (Press **Replay** to see it again.) ```tsx // shimmer-once.tsx "use client"; import * as React from "react"; import { Button } from "@/components/ui/cubby-ui/button"; export default function ShimmerOnce() { const [runId, setRunId] = React.useState(0); return (

Response generated.

); } ``` ## Disabling [#disabling] Use `shimmer-none` to render the text normally. It works in any class order, so the typical use is responsive or stateful: shimmer while loading, solid once resolved. ```tsx // shimmer-none.tsx export default function ShimmerNone() { return (

Shimmer on

Shimmer off

); } ``` ```tsx

Generating response…

``` ## Reverse [#reverse] Use `shimmer-reverse` to sweep the highlight in the opposite direction. In RTL layouts the sweep already follows the reading direction, so you only need this to flip it manually. ```tsx

Generating response…

``` ## Fallback & reduced motion [#fallback--reduced-motion] The shimmer uses modern color features (relative color syntax and `color-mix()`), available in all current browsers. In older browsers without support the highlight is dropped and the text may render transparent, so apply it conditionally with a `supports-*` variant if you target those: ```tsx

Generating response…

``` When the user prefers reduced motion, the animation is disabled automatically and the text renders normally, with nothing to configure. # Button Group (/docs/components/button-group) A button group puts related actions on one surface, like a file's share, duplicate, and archive, or an editor's undo and redo. A highlight glides between the buttons as you hover or tab along the row. When one button stays selected, use [`ToggleGroup`](/docs/components/toggle-group). When the row needs arrow-key navigation, use [`Toolbar`](/docs/components/toolbar). ## Preview [#preview] ```tsx // button-group-demo.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup, ButtonGroupSeparator, } from "@/components/ui/cubby-ui/button-group/button-group"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/cubby-ui/dropdown-menu"; import { HugeiconsIcon } from "@hugeicons/react"; import { Archive02Icon, ChevronDownIcon, Copy01Icon, Delete02Icon, Edit02Icon, Link04Icon, PinIcon, } from "@hugeicons/core-free-icons"; export default function ButtonGroupDemo() { return ( } > Rename Pin to top Delete ); } ``` ## Installation [#installation] ### Installation **CLI:** ```bash npx shadcn@latest add @cubby-ui/button-group ``` **Manual:** 1. Install dependencies: ```bash npm install class-variance-authority ``` 2. Copy the component source code to your project: Create `components/ui/cubby-ui/button-group/button-group.tsx`: ```tsx "use client"; import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/cubby-ui/separator"; import { solidSurface } from "@/lib/cubby-ui/elevated"; import "./button-group.css"; // attached: children keep their own paint and the group fuses them. // Trays (outline, soft, elevated, solid): the group draws the shell, quiets // buttons without a `variant`, and a pill glides between them // (button-group.css). Selectors match tags, not data-slot, because a Base UI // trigger rendering a Button stamps its own data-slot. const buttonGroupVariants = cva( "relative flex w-fit items-stretch [&>*:focus-visible]:relative [&>*:focus-visible]:z-10 [&>*[data-popup-open]]:relative [&>*[data-popup-open]]:z-10", { variants: { variant: { attached: "[&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[>[data-slot=button-group]]:gap-2", // Outline draws its line just outside the fill, as on an outline // Button. The others have no border, so elevated's rim and shadow // ring meet its edge. outline: "bg-(--outline) text-foreground outline outline-(--outline-border)", soft: "bg-secondary text-secondary-foreground", elevated: cn(solidSurface(3, 3), "text-foreground"), // The fill inverts the page, so the overlays flip (white in light, // black in dark) and run stronger to show. solid: "bg-neutral text-neutral-foreground [--group-highlight-fill:oklch(1_0_0/16%)] [--group-press-fill:oklch(1_0_0/22%)] dark:[--group-highlight-fill:oklch(0_0_0/16%)] dark:[--group-press-fill:oklch(0_0_0/22%)]", }, // Button's height ladder (one step taller below sm); corners step with it. size: { sm: "[--group-h:--spacing(9)] sm:not-pointer-coarse:[--group-h:--spacing(8)] [--group-radius:calc(var(--radius)-2px)]", default: "[--group-h:--spacing(10)] sm:not-pointer-coarse:[--group-h:--spacing(9)] [--group-radius:var(--radius)]", lg: "[--group-h:--spacing(11)] sm:not-pointer-coarse:[--group-h:--spacing(10)] [--group-radius:calc(var(--radius)+2px)]", }, orientation: { horizontal: "", vertical: "flex-col", }, }, compoundVariants: [ // Border rules come in pairs: one for children with real borders // (inputs, select triggers), and a before: twin for Button-recipe // children, whose border is on their paint pseudo-element. The twins // skip select triggers: before: would add an empty pseudo-element there // that becomes a flex item. { variant: "attached", orientation: "horizontal", className: "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>button:not(:first-child):not([data-slot=select-trigger])]:before:border-l-0 [&>a:not(:first-child)]:before:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>*:has(+[data-slot=button-group-separator])]:border-r-0 [&>button:has(+[data-slot=button-group-separator]):not([data-slot=select-trigger])]:before:border-r-0 [&>a:has(+[data-slot=button-group-separator])]:before:border-r-0 [&>button:first-of-type:not(:only-of-type)]:rounded-l-lg [&>button:first-of-type:not(:only-of-type):not([data-slot=select-trigger])]:before:border-l [&>[data-slot=select-trigger]:first-of-type:not(:only-of-type)]:border-l [&>a:first-of-type:not(:only-of-type)]:rounded-l-lg [&>a:first-of-type:not(:only-of-type)]:before:border-l [&>button:last-of-type:not(:only-of-type)]:rounded-r-lg [&>a:last-of-type:not(:only-of-type)]:rounded-r-lg", }, { variant: "attached", orientation: "vertical", className: "[&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>button:not(:first-child):not([data-slot=select-trigger])]:before:border-t-0 [&>a:not(:first-child)]:before:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>*:has(+[data-slot=button-group-separator])]:border-b-0 [&>button:has(+[data-slot=button-group-separator]):not([data-slot=select-trigger])]:before:border-b-0 [&>a:has(+[data-slot=button-group-separator])]:before:border-b-0 [&>button:first-of-type:not(:only-of-type)]:rounded-t-lg [&>button:first-of-type:not(:only-of-type):not([data-slot=select-trigger])]:before:border-t [&>[data-slot=select-trigger]:first-of-type:not(:only-of-type)]:border-t [&>a:first-of-type:not(:only-of-type)]:rounded-t-lg [&>a:first-of-type:not(:only-of-type)]:before:border-t [&>button:last-of-type:not(:only-of-type)]:rounded-b-lg [&>a:last-of-type:not(:only-of-type)]:rounded-b-lg", }, ], defaultVariants: { variant: "soft", size: "default", orientation: "horizontal", }, }, ); const trayClasses = cn( // Insets are item borders (button-group.css). Plate corners are the // shell's minus the 2px edge border. Select triggers skip the before: // rule, or its empty pseudo-element becomes a flex item. "rounded-(--group-radius) [--group-item-radius:max(0px,calc(var(--group-radius)-2px))]", "[&>:is(button,a):not([data-slot=select-trigger])]:before:rounded-(--group-item-radius)", // Highlight and press: 8% / 10% black overlays (white in dark). Button's 6% // reads too faint here, and a label tint shows a cool cast. Solid sets its own. "[--group-highlight:var(--group-highlight-fill,var(--surface-active))] [--group-press:var(--group-press-fill,var(--surface-selected))]", // Sizing: a row is the ladder height, a column at least the ladder width. // Items size themselves in button-group.css. "data-[orientation=horizontal]:h-(--group-h) data-[orientation=vertical]:w-fit", // Quiet children. A Button without a variant renders `primary`, so this // list (with text-current below) must undo everything `primary` sets: fill, // hover, press, border, and label color. A style added to `primary` needs // overriding here too. "[&>:is(button,a):not([data-variant])]:[--btn-bg:transparent] [&>:is(button,a):not([data-variant])]:[--btn-border:transparent] [&>:is(button,a):not([data-variant])]:[--btn-bg-hover:var(--group-highlight)] [&>:is(button,a):not([data-variant])]:[--btn-bg-active:var(--group-press)]", // Label color "[&>:is(button,a):not([data-variant])]:text-current", // Instant hover, so a sweep never shows two buttons half-lit. "[&>:is(button,a):not([data-variant])]:before:[transition-duration:0ms,100ms,100ms] [&>:is(button,a):not([data-variant])]:active:before:[transition-duration:100ms]", // Never narrower than its items; overflow is the container's to handle. "min-w-max", // Groups of groups are a layout wrapper with no shell "has-[>[data-slot=button-group]]:gap-2 has-[>[data-slot=button-group]]:bg-transparent has-[>[data-slot=button-group]]:p-0 has-[>[data-slot=button-group]]:border-0 has-[>[data-slot=button-group]]:outline-0 has-[>[data-slot=button-group]]:shadow-none has-[>[data-slot=button-group]]:h-auto has-[>[data-slot=button-group]]:w-fit", ); type ButtonGroupVariant = NonNullable< VariantProps["variant"] >; type ButtonGroupOrientation = NonNullable< VariantProps["orientation"] >; type ButtonGroupSize = NonNullable< VariantProps["size"] >; type ButtonGroupContextValue = { variant: ButtonGroupVariant; size: ButtonGroupSize; orientation: ButtonGroupOrientation; }; // Null outside a group, so nested groups know to inherit variant and size. const ButtonGroupContext = React.createContext( null, ); const DEFAULT_CONTEXT: ButtonGroupContextValue = { variant: "soft", size: "default", orientation: "horizontal", }; export type ButtonGroupProps = React.ComponentProps<"div"> & { /** Defaults to the parent group's variant, or `"soft"`. */ variant?: ButtonGroupVariant; /** Outer height, on Button's ladder. Defaults to the parent group's size, or `"default"`. */ size?: ButtonGroupSize; orientation?: ButtonGroupOrientation; /** Glide a shared highlight between buttons on hover and focus. Tray variants only. */ highlight?: boolean; }; function ButtonGroup({ className, variant: variantProp, size: sizeProp, orientation = "horizontal", highlight = true, children, ...props }: ButtonGroupProps) { const parent = React.useContext(ButtonGroupContext); const variant = variantProp ?? parent?.variant ?? DEFAULT_CONTEXT.variant; const size = sizeProp ?? parent?.size ?? DEFAULT_CONTEXT.size; const isTray = variant !== "attached"; const glides = isTray && highlight; const context = React.useMemo( () => ({ variant, size, orientation }), [variant, size, orientation], ); return (
{children} {glides && ( )}
); } export type ButtonGroupTextProps = useRender.ComponentProps<"div">; function ButtonGroupText({ className, render, ...props }: ButtonGroupTextProps) { const { variant } = React.useContext(ButtonGroupContext) ?? DEFAULT_CONTEXT; const defaultProps = { "data-slot": "button-group-text", className: cn( "flex items-center gap-2 text-sm font-medium whitespace-nowrap tabular-nums [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", variant === "attached" ? "bg-muted rounded-lg border border-transparent px-4" : // Inset like the buttons (button-group.css), so text lines up // with their labels. "px-3", className, ), }; return useRender({ defaultTagName: "div", render, props: mergeProps<"div">(defaultProps, props), }); } export type ButtonGroupSeparatorProps = React.ComponentProps; function ButtonGroupSeparator({ className, orientation, ...props }: ButtonGroupSeparatorProps) { const group = React.useContext(ButtonGroupContext) ?? DEFAULT_CONTEXT; const resolved = orientation ?? (group.orientation === "vertical" ? "horizontal" : "vertical"); return ( ); } export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants, }; ``` Create `components/ui/cubby-ui/button-group/button-group.css`: ```tsx /* ------------------------------------------------------------------------------------------------- * Gliding highlight. The lit button's plate (::before) is the anchor and the * pill follows it. Without anchor positioning, each button paints its own * hover. * -------------------------------------------------------------------------------------------------*/ [data-button-group-highlight] { display: none; } /* Hit areas: a square ::after over the whole box, corners included, so the * notch between two buttons isn't dead. Beside a separator it reaches the * line, stepping over a select's hidden input. */ [data-slot="button-group"]:not([data-variant="attached"]) > :is(button, a[href], [role="button"]) { position: relative; } [data-slot="button-group"]:not([data-variant="attached"]) > :is(button, a[href], [role="button"])::after { content: ""; position: absolute; inset-block: -2px; inset-inline: -1px; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="vertical"] > :is(button, a[href], [role="button"])::after { inset-block: -1px; inset-inline: -2px; } /* 1px border + 3px air + half the line. */ [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="horizontal"] > :is(button, a[href], [role="button"]):has( + [data-slot="button-group-separator"], + input[aria-hidden="true"] + [data-slot="button-group-separator"] )::after { inset-inline-end: -5px; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="horizontal"] > [data-slot="button-group-separator"] + :is(button, a[href], [role="button"])::after { inset-inline-start: -5px; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="vertical"] > :is(button, a[href], [role="button"]):has( + [data-slot="button-group-separator"], + input[aria-hidden="true"] + [data-slot="button-group-separator"] )::after { inset-block-end: -5px; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="vertical"] > [data-slot="button-group-separator"] + :is(button, a[href], [role="button"])::after { inset-block-start: -5px; } /* ------------------------------------------------------------------------------------------------- * Inset: item borders, 2px facing the shell's edge, 1px facing a neighbour. * Borders snap to whole device pixels, so the edge is never thinner than the * gap, and the shell has no border to push elevated's rim inward. `of` * counts items only, so Base UI's guard spans can't move the ends. * -------------------------------------------------------------------------------------------------*/ [data-slot="button-group"]:not([data-variant="attached"]) > :is(button, a, [data-slot="input"], [data-slot="button-group-text"]) { border: 1px solid transparent; border-radius: calc(var(--group-item-radius) + 2px); } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="horizontal"] > :is(button, a, [data-slot="input"], [data-slot="button-group-text"]) { border-block-width: 2px; height: auto; align-self: stretch; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="horizontal"] > :nth-child( 1 of button, a, [data-slot="input"], [data-slot="button-group-text"] ) { border-inline-start-width: 2px; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="horizontal"] > :nth-last-child( 1 of button, a, [data-slot="input"], [data-slot="button-group-text"] ) { border-inline-end-width: 2px; } /* A column: every item has the row's paint height, whatever its borders, * at least that width, and stretches to the widest. */ [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="vertical"] > :is(button, a, [data-slot="input"], [data-slot="button-group-text"]) { border-inline-width: 2px; box-sizing: content-box; height: calc(var(--group-h) - 4px); min-width: calc(var(--group-h) - 4px); width: auto; padding-block: 0; align-self: stretch; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="vertical"] > :nth-child( 1 of button, a, [data-slot="input"], [data-slot="button-group-text"] ) { border-block-start-width: 2px; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="vertical"] > :nth-last-child( 1 of button, a, [data-slot="input"], [data-slot="button-group-text"] ) { border-block-end-width: 2px; } /* Icon buttons: the paint is square, whatever each side's border. */ [data-slot="button-group"]:not([data-variant="attached"]) > :is(button, a)[data-size^="icon"] { box-sizing: content-box; aspect-ratio: 1; padding-inline: 0; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="horizontal"] > :is(button, a)[data-size^="icon"] { width: auto; } [data-slot="button-group"]:not( [data-variant="attached"] )[data-orientation="vertical"] > :is(button, a)[data-size^="icon"] { height: auto; } /* ------------------------------------------------------------------------------------------------- * Quiet fields. * * Inputs and select triggers drop their border and fill and take the * buttons' size. The pill glides onto a select trigger; an input lights in * place. * -------------------------------------------------------------------------------------------------*/ [data-slot="button-group"]:not([data-variant="attached"]) > :is([data-slot="input"], [data-slot="select-trigger"]) { /* Fill inside the inset border. */ background-color: transparent; background-clip: padding-box; box-shadow: none; } [data-slot="button-group"]:not([data-variant="attached"]) > [data-slot="input"] { padding-inline: 0.625rem; color: inherit; transition-property: background-color, outline-width, outline-color; /* Instant hover, like the buttons. */ transition-duration: 0ms, 100ms, 100ms; } /* Hover is gated to real hover: a raw :hover sticks after a tap. */ [data-slot="button-group"]:not([data-variant="attached"]) > [data-slot="input"]:focus { background-color: var(--group-highlight); } /* Select triggers paint their own hover and open state, like the buttons; * the @supports block turns it off where the pill glides. */ [data-slot="button-group"]:not([data-variant="attached"]) > [data-slot="select-trigger"][data-popup-open] { background-color: var(--group-highlight); } @media (hover: hover) { [data-slot="button-group"]:not([data-variant="attached"]) > :is([data-slot="input"], [data-slot="select-trigger"]):hover { background-color: var(--group-highlight); } } /* Select triggers have no paint layer of their own; this one is the anchor * for the pill. */ [data-slot="button-group"]:not([data-variant="attached"]) > [data-slot="select-trigger"]::before { content: ""; position: absolute; inset: 0; border-radius: var(--group-item-radius); pointer-events: none; } /* All anchor features or none: a partial implementation leaves the pill * unplaced or tied to another group. */ @supports (anchor-name: --button-group-item) and (anchor-scope: --button-group-item) and (inset: anchor(inside)) { [data-slot="button-group"][data-glide] { anchor-scope: --button-group-item; } /* The pill stands in for hover. */ [data-slot="button-group"][data-glide] > :is(button, a[href], [role="button"]):not([data-variant]) { --btn-bg-hover: transparent; --btn-bg-active: transparent; } /* Matches the specificity of the select rules above. */ [data-slot="button-group"][data-glide]:not([data-variant="attached"]) > [data-slot="select-trigger"]:is(:hover, [data-popup-open]) { background-color: transparent; } /* Real hover only (:active covers taps). An open menu, a held button, or * keyboard focus keeps the pill. */ [data-slot="button-group"][data-glide] > :is(button, a[href], [role="button"]):not(:disabled, [data-disabled]):is( :focus-visible, :active, [data-popup-open] )::before { anchor-name: --button-group-item; } /* Checks siblings: a `:has()` on the group would restyle every button on * any DOM change. */ @media (hover: hover) { [data-slot="button-group"][data-glide] > :is(button, a[href], [role="button"]):not( :disabled, [data-disabled], [data-popup-open], :active, :focus-visible ):not( :has( ~ [data-popup-open], ~ :active, ~ :is(button, a[href], [role="button"]):focus-visible ), [data-popup-open] ~ *, :active ~ *, :is(button, a[href], [role="button"]):focus-visible ~ * ):hover::before { anchor-name: --button-group-item; } } [data-slot="button-group"][data-glide] > [data-button-group-highlight] { display: block; position: absolute; position-anchor: --button-group-item; inset: anchor(inside); border-radius: var(--group-item-radius); background-color: var(--group-highlight); /* Rendered last, so it sits under the buttons inside the isolated group. */ z-index: -1; pointer-events: none; opacity: 0; /* Glides between buttons; appears and goes instantly. */ transition: inset 80ms var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1)), scale 100ms var(--ease-out, cubic-bezier(0, 0, 0.2, 1)), background-color 100ms var(--ease-out, cubic-bezier(0, 0, 0.2, 1)); } [data-slot="button-group"][data-glide]:has( > :is(button, a[href], [role="button"]):not( :disabled, [data-disabled] ):is(:focus-visible, :active, [data-popup-open]) ) > [data-button-group-highlight] { opacity: 1; } @media (hover: hover) { [data-slot="button-group"][data-glide]:has( > :is(button, a[href], [role="button"]):not( :disabled, [data-disabled] ):hover ) > [data-button-group-highlight] { opacity: 1; } } /* Press: the plate holds still and the pill shrinks. Menu triggers skip * it. */ [data-slot="button-group"][data-glide] > :is(button, a[href], [role="button"]):not([data-variant]):active::before { scale: none; } [data-slot="button-group"][data-glide]:has( > :is(button, a[href], [role="button"]):active:not( [aria-haspopup], :disabled ) ) > [data-button-group-highlight] { scale: var(--group-press-scale, 0.98); background-color: var(--group-press); } @media (prefers-reduced-motion: reduce) { [data-slot="button-group"][data-glide] { --group-press-scale: 1; } [data-slot="button-group"][data-glide] > [data-button-group-highlight] { transition: background-color 100ms var(--ease-out, cubic-bezier(0, 0, 0.2, 1)); } } } /* ------------------------------------------------------------------------------------------------- * Focus ring: 2px, at -1px offset so it straddles the edge. Items draw * it on their plate (::before); fields, which have none, as an inset shadow, * with an outline under forced colors. * -------------------------------------------------------------------------------------------------*/ [data-slot="button-group"]:not([data-variant="attached"]) > :is(button, a):focus-visible { outline-color: transparent; } [data-slot="button-group"]:not([data-variant="attached"]) > :is(button, a):focus-visible::before { outline: 2px solid var(--color-ring); outline-offset: -1px; } [data-slot="button-group"]:not([data-variant="attached"]) > [data-slot="input"]:focus-visible { outline-color: transparent; box-shadow: inset 0 0 0 2px var(--color-ring); } @media (forced-colors: active) { [data-slot="button-group"]:not([data-variant="attached"]) > [data-slot="input"]:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; } } ``` Create `lib/cubby-ui/elevated.tsx`: ```tsx import type * as React from "react"; import { cn } from "@/lib/utils"; export type SurfaceLevel = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; /** Every `SurfaceLevel`, for exhaustive iteration. Keep in sync with the type. */ export const SURFACE_LEVELS = [ 1, 2, 3, 4, 5, 6, 7, 8, ] as const satisfies readonly SurfaceLevel[]; export const SURFACE_BG: Record = { 1: "bg-surface-1", 2: "bg-surface-2", 3: "bg-surface-3", 4: "bg-surface-4", 5: "bg-surface-5", 6: "bg-surface-6", 7: "bg-surface-7", 8: "bg-surface-8", }; /** * Every shadow below uses the `shadow-(--var)` form, pointing at a single theme * variable. Two reasons, and both break if you "simplify" it back: * * 1. tailwind-merge only treats this form as the `shadow` group. A theme-key * name and a square-bracket arbitrary value both land in `shadow-color`, so * a caller's `className="shadow-none"` never overrides them and stylesheet * order silently picks the winner. (Do not spell those two forms out here: * Tailwind scans comments, and a bracket example compiles to a real, * broken utility.) * 2. The variable is a `--surface-*` source token, not a `@theme` alias. Source * tokens are re-declared in `.dark`, so they resolve per element. A `@theme` * alias is declared once on `:root`, which makes a `.dark` scoped to a * subtree inherit the light value (same trap as `--border`). */ export const SURFACE_SHADOW: Record = { 1: "shadow-(--surface-shadow-1)", 2: "shadow-(--surface-shadow-2)", 3: "shadow-(--surface-shadow-3)", 4: "shadow-(--surface-shadow-4)", 5: "shadow-(--surface-shadow-5)", 6: "shadow-(--surface-shadow-6)", 7: "shadow-(--surface-shadow-7)", 8: "shadow-(--surface-shadow-8)", }; export const SURFACE_RIM: Record = { 1: "after:shadow-(--surface-rim-1)", 2: "after:shadow-(--surface-rim-2)", 3: "after:shadow-(--surface-rim-3)", 4: "after:shadow-(--surface-rim-4)", 5: "after:shadow-(--surface-rim-5)", 6: "after:shadow-(--surface-rim-6)", 7: "after:shadow-(--surface-rim-7)", 8: "after:shadow-(--surface-rim-8)", }; /** * Exposes the surface level to descendants as `--popup-surface` so children * (arrow fills, sticky labels, fade gradients) can track the popup's level * via `bg-(--popup-surface,var(--popover))`. */ export const SURFACE_VAR: Record = { 1: "[--popup-surface:var(--surface-1)]", 2: "[--popup-surface:var(--surface-2)]", 3: "[--popup-surface:var(--surface-3)]", 4: "[--popup-surface:var(--surface-4)]", 5: "[--popup-surface:var(--surface-5)]", 6: "[--popup-surface:var(--surface-6)]", 7: "[--popup-surface:var(--surface-7)]", 8: "[--popup-surface:var(--surface-8)]", }; /** * Drops + rim insets in a single `box-shadow` (no `::after`). Used by `solidSurface()`. * Literal Tailwind classes so the scanner picks them up. */ export const SURFACE_SHADOW_COMBINED: Record = { 1: "shadow-(--surface-shadow-combined-1)", 2: "shadow-(--surface-shadow-combined-2)", 3: "shadow-(--surface-shadow-combined-3)", 4: "shadow-(--surface-shadow-combined-4)", 5: "shadow-(--surface-shadow-combined-5)", 6: "shadow-(--surface-shadow-combined-6)", 7: "shadow-(--surface-shadow-combined-7)", 8: "shadow-(--surface-shadow-combined-8)", }; /** * Near-black fill in BOTH themes, deliberately OFF the elevation ladder. For * persistent app chrome (a header bar) and for floating labels that should read * as instrument rather than page. * * Pair it with `data-surface="chrome"` on the same element — that attribute * re-points `--foreground`, `--border` and the washes for the subtree, so * ordinary page classes keep working inside. Without it, contents paint * dark-on-dark. * * Fill only, like `SURFACE_BG` + `SURFACE_VAR`. Add the edge separately with * `shadow-(--chrome-shadow)` for anything LARGE: in dark it is not decoration, * since the fill measures 1.08:1 against the page and that token is the only * thing defining the surface (light is 16.3:1 and needs none). Leave it off * for anything small, as the chrome `Tooltip` does. */ export const CHROME_SURFACE = "bg-chrome [--popup-surface:var(--chrome)]"; export function surfaceClasses( level: SurfaceLevel, shadowLevel: SurfaceLevel = level, ): string { return `${SURFACE_BG[level]} ${SURFACE_SHADOW[shadowLevel]} ${SURFACE_VAR[level]}`; } /** * Like `surfaceClasses`, but adds a `::after` overlay that re-paints the rim * above the container's children. Use on elevated containers with opaque * children near their edges (sticky labels, pinned inputs, dialog headers). * * Host requirements: positioned, `border-radius` class, clipped overflow. * Overlay is `z-index: 2` — bump if children exceed that. */ export function elevatedSurface( level: SurfaceLevel, shadowLevel: SurfaceLevel = level, ): string { return `${SURFACE_BG[level]} ${SURFACE_SHADOW[shadowLevel]} ${SURFACE_RIM[level]} ${SURFACE_VAR[level]} after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:z-[2]`; } /** * Like `elevatedSurface`, but paints the rim in the popup's own `box-shadow` * (no `::after`) — leaves `::after` free for other purposes. Use for elevated * containers with no opaque children near their edges; switch to * `elevatedSurface()` if you add a sticky/opaque child. * * When `level !== shadowLevel` the rim color tracks `shadowLevel`; for * independent rim/drop control use `elevatedSurface` instead. */ export function solidSurface( level: SurfaceLevel, shadowLevel: SurfaceLevel = level, ): string { return `${SURFACE_BG[level]} ${SURFACE_SHADOW_COMBINED[shadowLevel]} ${SURFACE_VAR[level]}`; } /** * `::after` classes for a 1px inset rim on a single edge — for viewport-flush * containers (Sheet/Drawer) so the rim only shows on the inner-facing edge. * Color is `--surface-rim-color` (transparent in light, ~4% white in dark). */ export const SURFACE_RIM_EDGE: Record< "top" | "bottom" | "left" | "right", string > = { top: "after:shadow-[inset_0_1px_0_0_var(--surface-rim-color)]", bottom: "after:shadow-[inset_0_-1px_0_0_var(--surface-rim-color)]", left: "after:shadow-[inset_1px_0_0_0_var(--surface-rim-color)]", right: "after:shadow-[inset_-1px_0_0_0_var(--surface-rim-color)]", }; /** * `::after` overlay with a 1px rim on the specified inner-facing edge. * Pair with `surfaceClasses()` (NOT `elevatedSurface()`) for flush variants. */ export function innerEdgeRim( innerEdge: "top" | "bottom" | "left" | "right", ): string { return `after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:z-[2] ${SURFACE_RIM_EDGE[innerEdge]}`; } /** Maps attach-side → inner-facing edge for `innerEdgeRim`. */ export const INNER_EDGE_FROM_ATTACH_SIDE: Record< "top" | "bottom" | "left" | "right", "top" | "bottom" | "left" | "right" > = { top: "bottom", bottom: "top", left: "right", right: "left", }; /** * Directional drop shadow for a viewport-flush surface: casts toward the * inner-facing edge (into the content) rather than always straight down. A * flush surface only sheds shadow on the one edge that isn't pinned to the * viewport, so the radial `--surface-shadow-N` recipe (which casts down on * every surface) points the wrong way for top/bottom/left attach sides. * * This is the level-5 recipe, rotated per edge. It matches the floating * variant's edge ring EXACTLY by mirroring how that recipe handles the ring * per mode: the base shadow uses --surface-shadow-ring (light: 4.5%) and the * `dark:` shadow inlines oklch(0 0 0 / 0.12) (the level-5 dark ring). The drop * layers reference --surface-shadow-near/mid/far, which already redefine per * mode, so they're identical between the two and don't need splitting. * The ring casts on all four edges — the three viewport-pinned ones fall * off-screen. */ export const INNER_EDGE_SHADOW: Record< "top" | "bottom" | "left" | "right", string > = { left: "shadow-[0_0_0_1px_var(--surface-shadow-ring),-1px_0_1px_-0.5px_var(--surface-shadow-near),-3px_0_3px_-1.5px_var(--surface-shadow-mid),-6px_0_6px_-3px_var(--surface-shadow-mid),-12px_0_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),-1px_0_1px_-0.5px_var(--surface-shadow-near),-3px_0_3px_-1.5px_var(--surface-shadow-mid),-6px_0_6px_-3px_var(--surface-shadow-mid),-12px_0_12px_-6px_var(--surface-shadow-far)]", right: "shadow-[0_0_0_1px_var(--surface-shadow-ring),1px_0_1px_-0.5px_var(--surface-shadow-near),3px_0_3px_-1.5px_var(--surface-shadow-mid),6px_0_6px_-3px_var(--surface-shadow-mid),12px_0_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),1px_0_1px_-0.5px_var(--surface-shadow-near),3px_0_3px_-1.5px_var(--surface-shadow-mid),6px_0_6px_-3px_var(--surface-shadow-mid),12px_0_12px_-6px_var(--surface-shadow-far)]", top: "shadow-[0_0_0_1px_var(--surface-shadow-ring),0_-1px_1px_-0.5px_var(--surface-shadow-near),0_-3px_3px_-1.5px_var(--surface-shadow-mid),0_-6px_6px_-3px_var(--surface-shadow-mid),0_-12px_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),0_-1px_1px_-0.5px_var(--surface-shadow-near),0_-3px_3px_-1.5px_var(--surface-shadow-mid),0_-6px_6px_-3px_var(--surface-shadow-mid),0_-12px_12px_-6px_var(--surface-shadow-far)]", bottom: "shadow-[0_0_0_1px_var(--surface-shadow-ring),0_1px_1px_-0.5px_var(--surface-shadow-near),0_3px_3px_-1.5px_var(--surface-shadow-mid),0_6px_6px_-3px_var(--surface-shadow-mid),0_12px_12px_-6px_var(--surface-shadow-far)] dark:shadow-[0_0_0_1px_oklch(0_0_0/0.12),0_1px_1px_-0.5px_var(--surface-shadow-near),0_3px_3px_-1.5px_var(--surface-shadow-mid),0_6px_6px_-3px_var(--surface-shadow-mid),0_12px_12px_-6px_var(--surface-shadow-far)]", }; /** * Flush-surface elevation: bg + a directional drop shadow cast toward the * inner edge + the single-edge inset rim on that edge. Use for viewport-flush * containers (Sheet/Drawer `default` variant) instead of `solidSurface` (whose * all-around, downward shadow only suits a free-floating surface). `innerEdge` * is the content-facing edge — derive it from the attach side via * `INNER_EDGE_FROM_ATTACH_SIDE`. */ export function flushSurface( level: SurfaceLevel, innerEdge: "top" | "bottom" | "left" | "right", ): string { return `${SURFACE_BG[level]} ${SURFACE_VAR[level]} ${INNER_EDGE_SHADOW[innerEdge]} ${innerEdgeRim(innerEdge)}`; } export interface ElevatedProps extends React.ComponentProps<"div"> { level: SurfaceLevel; shadowLevel?: SurfaceLevel; } function Elevated({ level, shadowLevel, className, ...props }: ElevatedProps) { return (
); } export { Elevated }; ``` ## Usage [#usage] **Imports:** ```tsx import { ButtonGroup, ButtonGroupSeparator, } from "@/components/ui/cubby-ui/button-group/button-group"; ``` **Basic Usage:** ```tsx ``` ## Composition [#composition] ```text ButtonGroup ├── Button ├── ButtonGroupSeparator └── ButtonGroupText ``` Leave `variant` unset on the buttons inside; they take the group's style. A shell never gets narrower than its buttons. If it may not fit on small screens, put it in a container that scrolls sideways, such as a [`ScrollArea`](/docs/components/scroll-area) with `nativeScroll`. To let a field shrink instead, pass `className="min-w-0"` to the group. ## Examples [#examples] ### Variants [#variants] Use `variant` to pick the shell the buttons sit on. ```tsx // button-group-variants.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup, ButtonGroupSeparator, } from "@/components/ui/cubby-ui/button-group/button-group"; import { HugeiconsIcon } from "@hugeicons/react"; import { Archive02Icon, Copy01Icon, Delete02Icon, PinIcon, } from "@hugeicons/core-free-icons"; const VARIANTS = ["outline", "soft", "elevated", "solid"] as const; export default function ButtonGroupVariants() { return (
{VARIANTS.map((variant) => ( ))}
); } ``` | Variant | Description | | ---------- | ------------------------------------------------------------- | | `outline` | An outline Button's fill and border. | | `soft` | The default. A secondary Button's fill, no edge. | | `elevated` | A raised surface, for toolbars that float. | | `solid` | The neutral fill, for a primary action and options. | | `attached` | No shell; joins buttons to fields. See [Attached](#attached). | ### Separator [#separator] Use `ButtonGroupSeparator` to split actions into clusters; it hides beside the highlight. ```tsx // button-group-separator-demo.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup, ButtonGroupSeparator, } from "@/components/ui/cubby-ui/button-group/button-group"; import { HugeiconsIcon } from "@hugeicons/react"; import { Comment01Icon, Link04Icon, PlayIcon, Redo02Icon, Undo02Icon, } from "@hugeicons/core-free-icons"; export default function ButtonGroupSeparatorDemo() { return ( ); } ``` ### Emphasis [#emphasis] Give one button a `variant` to mark the main action; it keeps its own fill. ```tsx // button-group-emphasis.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; import { HugeiconsIcon } from "@hugeicons/react"; import { Archive02Icon, ArrowTurnForwardIcon, Clock01Icon, Delete02Icon, MailReply01Icon, } from "@hugeicons/core-free-icons"; export default function ButtonGroupEmphasis() { return ( ); } ``` ### Pill [#pill] Set `[--radius:9999px]` on the group for a pill; the buttons' corners follow. ```tsx … ``` ### With Text [#with-text] Use `ButtonGroupText` for a read-only label between buttons, like a selection count. For a value people type or step, use [`NumberField`](/docs/components/number-field). ```tsx // button-group-text.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, } from "@/components/ui/cubby-ui/button-group/button-group"; import { HugeiconsIcon } from "@hugeicons/react"; import { Archive02Icon, Cancel01Icon, Delete02Icon, FolderTransferIcon, Tag01Icon, } from "@hugeicons/core-free-icons"; export default function ButtonGroupTextExample() { return ( 3 selected ); } ``` ### With Fields [#with-fields] Put an `Input` or `Select` in a shell and it drops its border to sit in the row like a button. ```tsx // button-group-fields.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup, ButtonGroupSeparator, } from "@/components/ui/cubby-ui/button-group/button-group"; import { Input } from "@/components/ui/cubby-ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/cubby-ui/select"; import { HugeiconsIcon } from "@hugeicons/react"; import { PlusSignIcon } from "@hugeicons/core-free-icons"; const STATUSES = [ { label: "Open", value: "open" }, { label: "In progress", value: "in-progress" }, { label: "Closed", value: "closed" }, ]; export default function ButtonGroupFields() { return ( ); } ``` ### With Dropdown [#with-dropdown] Pair an action with a menu of related ones; the trigger keeps the highlight while the menu is open. ```tsx // button-group-dropdown.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup, ButtonGroupSeparator, } from "@/components/ui/cubby-ui/button-group/button-group"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/cubby-ui/dropdown-menu"; import { HugeiconsIcon } from "@hugeicons/react"; import { ChevronDownIcon, Clock01Icon, FloppyDiskIcon, GlobeIcon, } from "@hugeicons/core-free-icons"; export default function ButtonGroupDropdown() { return ( } > Schedule Save as draft ); } ``` ### Orientation [#orientation] Set `orientation="vertical"` to stack the buttons; separators turn to match. ```tsx // button-group-orientation.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup, ButtonGroupSeparator, } from "@/components/ui/cubby-ui/button-group/button-group"; import { HugeiconsIcon } from "@hugeicons/react"; import { FullScreenIcon, LockIcon, SearchAddIcon, SearchMinusIcon, ViewIcon, } from "@hugeicons/core-free-icons"; export default function ButtonGroupOrientation() { return ( ); } ``` ### Size [#size] Use `size` to match the group's height to a Button of the same size. Icon-only buttons still need an `icon` size to stay square. ```tsx // button-group-size.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; import { HugeiconsIcon } from "@hugeicons/react"; import { TextBoldIcon, TextItalicIcon, TextUnderlineIcon, } from "@hugeicons/core-free-icons"; const SIZES = ["sm", "default", "lg"] as const; export default function ButtonGroupSize() { return (
{SIZES.map((size) => ( ))}
); } ``` ### Nested [#nested] Nest groups to lay out several shells; inner groups inherit the outer `variant` and `size`. ```tsx // button-group-nested.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; import { HugeiconsIcon } from "@hugeicons/react"; import { ChevronLeftIcon, ChevronRightIcon } from "@hugeicons/core-free-icons"; const PAGES = [1, 2, 3, 4, 5]; export default function ButtonGroupNested() { return ( {PAGES.map((page) => ( ))} ); } ``` ### Attached [#attached] Use `variant="attached"` to join buttons, inputs, and selects into one control, with their own borders and variants. ```tsx // button-group-attached.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; export default function ButtonGroupAttached() { return ( ); } ``` Keep every part of an attached group in one style. These examples pair outline fields (`variant="outline"`) with outline buttons, so the shared edges separate the parts. For a filled group, pair filled fields with `field` buttons, which share the field's tint, and use a `tonal` button for the action. ```tsx // button-group-input.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; import { Input } from "@/components/ui/cubby-ui/input"; import { HugeiconsIcon } from "@hugeicons/react"; import { Search01Icon } from "@hugeicons/core-free-icons"; export default function ButtonGroupInput() { return ( ); } ``` ```tsx // button-group-select.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; import { Input } from "@/components/ui/cubby-ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/cubby-ui/select"; import { HugeiconsIcon } from "@hugeicons/react"; import { Sent02Icon } from "@hugeicons/core-free-icons"; const currencies = [ { label: "USD", value: "usd" }, { label: "EUR", value: "eur" }, { label: "GBP", value: "gbp" }, ]; export default function ButtonGroupSelect() { return ( ); } ``` ```tsx // button-group-popover.tsx import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; import { Input } from "@/components/ui/cubby-ui/input"; import { Label } from "@/components/ui/cubby-ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/cubby-ui/popover"; import { HugeiconsIcon } from "@hugeicons/react"; import { SparklesIcon } from "@hugeicons/core-free-icons"; export default function ButtonGroupPopover() { return ( } aria-label="Open Copilot assistant" /> } > Copilot

Task Description

Describe what you would like the AI to help you with.

); } ``` ```tsx // button-group-input-group.tsx "use client"; import * as React from "react"; import { Button } from "@/components/ui/cubby-ui/button"; import { ButtonGroup } from "@/components/ui/cubby-ui/button-group/button-group"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, } from "@/components/ui/cubby-ui/input-group"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/cubby-ui/tooltip"; import { HugeiconsIcon } from "@hugeicons/react"; import { AudioWave02Icon, PlusSignIcon } from "@hugeicons/core-free-icons"; export default function ButtonGroupInputGroup() { const [voiceEnabled, setVoiceEnabled] = React.useState(false); return ( setVoiceEnabled(!voiceEnabled)} size="icon" data-active={voiceEnabled} className="data-[active=true]:text-orange-700 data-[active=true]:[--btn-bg-active:var(--color-orange-100)]! data-[active=true]:[--btn-bg-hover:var(--color-orange-100)]! data-[active=true]:[--btn-bg:var(--color-orange-100)]! dark:data-[active=true]:text-orange-100 dark:data-[active=true]:[--btn-bg-active:var(--color-orange-800)]! dark:data-[active=true]:[--btn-bg-hover:var(--color-orange-800)]! dark:data-[active=true]:[--btn-bg:var(--color-orange-800)]!" aria-pressed={voiceEnabled} > } /> Voice Mode ); } ``` ## Accessibility [#accessibility] ### Labels [#labels] The group renders `role="group"`. Give it an `aria-label` that names the set ("File actions"), and give every icon-only button its own. ### Keyboard [#keyboard] Tab moves through the buttons one at a time. For arrow-key navigation along a long row, use [`Toolbar`](/docs/components/toolbar). ### Highlight [#highlight] The highlight follows the pointer and keyboard focus and skips disabled buttons. Focus also draws a ring, so the highlight is never the only cue. With reduced motion, it jumps instead of sliding. Browsers without CSS anchor positioning show a hover color on each button instead. ## API Reference [#api-reference] Button Group is a custom component built from plain HTML elements; every prop below is specific to it. Each part also accepts the props of the element it renders. ### Props [#props] #### ButtonGroup [#buttongroup] Groups buttons on one surface. Renders a `div` with `role="group"`. - **variant** (type: `string`, full: `"outline" | "soft" | "elevated" | "solid" | "attached"`) - Default: `"soft"` Shell style. `attached` draws no shell and joins its children. A nested group inherits its parent's variant. - **size** (type: `string`, full: `"sm" | "default" | "lg"`) - Default: `"default"` Sets the outer height to Button's `sm`, `default`, or `lg`. Has no effect on `attached`. - **orientation** (type: `string`, full: `"horizontal" | "vertical"`) - Default: `"horizontal"` Lays the buttons out in a row or a column. - **highlight** (type: `boolean`) - Default: `true` Glides one shared highlight between buttons. With `false`, each button paints its own hover. Has no effect on `attached`. #### ButtonGroupSeparator [#buttongroupseparator] A line between buttons. Wraps [`Separator`](/docs/components/separator). - **orientation** (type: `string`, full: `"horizontal" | "vertical"`) - Default: `perpendicular to the group` Direction of the line: vertical in a row, horizontal in a column. #### ButtonGroupText [#buttongrouptext] A read-only segment for a label or value. Renders a `div`; pass `render={