Getting started
Design principles
Ten rules the whole library is built on. Every one of them came from getting it wrong first, and each has a comment sitting exactly where somebody would reintroduce it.
The ten
These are not aspirations. They are the constraints a component has to satisfy before it ships, and a component that breaks one owes an explanation in its README — which is a real thing that has happened exactly once, and the explanation is in src/command/README.md.
1 · Feel is the product
Headless libraries won the argument about logic. They did not win the argument about feel, and feel is what people notice: a menu growing out of the corner nearest the thing that opened it, a sheet that gives when you pull it the wrong way, a thumb that stays exactly under a finger instead of springing towards it.
So every component arrives with its look, its movement and its accessibility already decided. There is no headless mode and there will not be one — shipping both means every component has two surfaces to keep correct, and one of them is always behind.
2 · One easing, and it is an ease-out
cubic-bezier(0.22, 1, 0.36, 1), everywhere. The first frames are the ones being watched, and anything that eases in spends them standing still.
Nothing in the package uses ease-in. Everything is under 300ms. Exits are faster than entrances and are never the entrance reversed — a panel that leaves as slowly as it arrived holds the page hostage twice. Springs are reserved for what a pointer drives, because a spring keeps its velocity where a tween restarts from zero. Nothing appears from scale(0): entrances start at 0.94–0.98, which is the difference between something arriving and something being switched on.
| What it is | How long |
|---|---|
| Press feedback | 100–160ms |
| Tooltip, small tip | 125–200ms |
| Menu, dropdown, popover | 150–250ms |
| Dialog, drawer | 200–500ms |
3 · Identity is data, structure is utilities
A consumer cannot override bg-green-500 buried in a component. So anything that distinguishes one state from another is an object of plain CSS colour strings, merged over the defaults, with the key set left open — which means the same mechanism that restyles a built-in state invents a new one.
import { Toaster, defaultTones, toast } from '@carabine/ui/toast';
// Restyle a state, and invent one, with the same prop. The merge is per state,
// so a state you are changing spreads the default it is changing.
<Toaster
tones={{
success: { ...defaultTones.success, cells: ['#34d399', '#10b981', '#059669'] },
deploy: {
icon: <Rocket className="size-4" />,
color: { dark: '#a78bfa', light: '#7c3aed' },
cells: ['#c4b5fd', '#a78bfa', '#8b5cf6'],
sticky: true,
},
}}
/>
// The new key is a real state the moment the tone exists.
toast('deploy', 'Shipping to production…');The same shape appears as variants on the button and as palette on the other eighteen. Structure — padding, radius, layout — stays in Tailwind utilities, where it belongs and where nobody needs to override it.
4 · The theme is a prop, never the operating system
There is not one dark: variant in the library.
dark: resolves from the OS, so it fires on a light-themed page running on a dark-mode machine — a component that is correct in isolation and wrong on the page that embeds it. Both palettes are written out as data and the caller says which one applies.
<Slider theme="light" defaultValue={40} />5 · Every prop optional, every default shippable
<Toaster /> with no props at all is the version we would ship. Nothing is required, nothing throws for being absent, and no component needs a configuration pass before it looks finished.
The other half of that rule: dimensions are props, not literals. If a consumer might reasonably want another value — a width, an offset, a gap, a queue cap — it is a prop with a default, not a constant in the file.
6 · Duplication over coupling
No component imports another. There is no shared internal package. The popover, the menu, the tooltip and the select each carry their own copy of the same placement arithmetic — four copies, on purpose.
The fourth copy is still cheaper than the edge that would let one component's placement bug become four components' regression.
It also keeps a component a thing you can copy out: one flat folder, no imports leaving it, which is what makes copy-paste and a registry-style install possible later without rewriting anything.
7 · Items are data, not children
No Menu.Root / Menu.Item / Menu.Separator. A list of commands in a real application is an array that gets built, filtered, reordered and permission-checked — so it is passed as an array.
const commands = [
{ id: 'rename', label: 'Rename', shortcut: 'F2' },
{ id: 'duplicate', label: 'Duplicate' },
{ id: 'delete', label: 'Delete', danger: true },
].filter((item) => can(item.id));
<Menu items={commands} onSelect={run}>
<button>Actions</button>
</Menu>Compound children look flexible and then charge for it: every consumer writes the same six elements, and the component still has to validate that they are in the right order.
8 · Measure, do not assume
Card heights, popover sizes, indicator positions, track widths — read the DOM. offsetWidth, never an index multiplied by a width, and never a bounding rect on anything animated, because a rect includes the transform that is currently mid-flight. Anything JavaScript measures and CSS paints comes from one place.
And measure defensively: ResizeObserver, matchMedia and friends are absent in a test renderer and in old browsers. They are guarded inside the component rather than polyfilled in a test setup, because a consumer's environment may lack them too.
9 · One claim, one surface
Hover and keyboard focus are the same question — this is the one you are on — and answering it twice leaves a component pointing at two rows.
One active row, one bar, one highlight. A list with a hover fill and a focus ring and a selected background is three components arguing about where you are.
10 · Accessibility is structural
Not a pass at the end. Live regions mount before the content they announce — a viewport mounted at the moment a toast fires announces nothing. Icon-only controls have labels, and every label is a prop because it will be translated. prefers-reduced-motion removes movement everywhere, not only where it was convenient.
Reduced motion is gentler, not absent: position and scale go, opacity and colour stay, because they carry meaning. A control that stops answering the pointer reads as broken.
The material
Glass is the house material for everything that floats over the page, and it comes with two rules that are not negotiable.
Translucent only with a blur behind it. A translucent surface without a backdrop-filter shows whatever the page is made of, sharply — a gradient, another component, a dotted background. The line to hold is tint versus object:
| Rule | Where | |
|---|---|---|
| Tint | May be translucent — it has no hairline and no shadow, and letting the surface through is the point | The hover list’s bar, the menu’s highlight |
| Object | May not — it carries a hairline or a shadow, so it claims to rest on top of something | Popovers, cards, panels |
| Field | Neither. A field is a hole, and a hole is opaque because you read against it | Text field, OTP, select trigger |
One density for anything text is read against. Glass comes in two weights and they are not interchangeable: a shell is only ever seen as a ring around a core, so it can be light; a core is what content sits on, so it is heavier. Whichever layer the text lands on carries the core's weight — a single-layer panel is a core, not a shell.
There is one exception in the library and it is the toast's. A card is the only glass here that floats over another copy of itself, so its core is opaque: what would show through is the card behind it, offset and scaled down, which reads as a rendering fault rather than as material. The blur cannot rescue it either — a transform on an ancestor establishes a backdrop root, and backdrop-filter inside one has nothing left to sample.
What follows
Principles are only worth writing down if something is refused because of them. These are the standing refusals, and they are the API as much as the props are.
| Refused | Which principle |
|---|---|
| A headless build | Feel is the product |
| dark: variants | The theme is a prop |
| A shared internal package | Duplication over coupling |
| Compound component APIs | Items are data |
| transition-all, and any property that is not one | One easing |
| setState per frame or per pointermove | One easing |
| A required prop on any component | Every default shippable |
| Unbounded queues, retries or caller-grown lists | Measure, do not assume |