Getting started
Theming
Both palettes are written out, the theme is a prop, and anything that identifies one state from another is data rather than a class.
The theme is a prop
There is not one dark: variant in this library.
dark: resolves from the operating system, 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. Every component takes theme, and both palettes are written out as plain values.
<Slider theme="light" defaultValue={40} />
<Toaster theme={theme} /> // one value, threaded from wherever you keep itThere is no provider and no context, which is deliberate: a context would make the theme ambient, and an ambient theme is one more thing that can be wrong somewhere you are not looking. If threading a prop through your tree is tedious, wrap the components you use once — that wrapper is three lines and it is yours.
// your app, not the library
const ThemeContext = createContext<'dark' | 'light'>('dark');
export function Button(props: ButtonProps) {
return <CarabineButton theme={useContext(ThemeContext)} {...props} />;
}Colours that identify are data
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. Structure — padding, radius, layout — stays in utilities, where nobody needs to reach it.
| Component | The prop | What it holds |
|---|---|---|
| Toast | tones | Per state: an icon, a colour per theme, three strip shades, an action |
| Button | variants | Per variant: background, colour, ring, hover, sweep — per theme |
| Switch | skin | Track, thumb and rings — merged over the theme’s, one field at a time |
| Hover list | bar | One plain CSS string: the colour of the sliding bar |
| Progress | color / track | One colour, or three brightest first |
| Color picker | rings | The whole palette — it replaces rather than merges |
Two shapes, and the difference matters. tones, variants and skin are merged, so one field changes without restating the rest. rings replaces, because a colour set is a whole rather than a table of variants to patch.
className, style and data-*
Colours are data and dimensions are props, and neither covers a margin, a font, a shadow, or a class from your own system. So every component takes `className` and `style`, merged after its own — yours wins, without !important and without having to know what was already there.
<Accordion items={faq} className="my-8 font-serif" />
<Slider label="Volume" style={{ maxWidth: 320 }} />On the eight components that portal a panel — popover, menu, tooltip, dialog, drawer, select, command palette, colour picker — they land on the panel. The trigger is already your element: you style it where you write it, and the panel is the part that portals away from your markup and is otherwise out of reach.
State, in the markup
So a rule can answer which state is this without knowing a single class name of ours.
[data-state='open'] { … } /* dialog, drawer, menu, popover, select, accordion header */
[data-state='on'] { … } /* switch */
[data-state='loading'] { … } /* skeleton */
[data-state='indeterminate'] { … } /* progress */
[data-disabled] { … }
[data-side='left'] { … } /* drawer, and anything anchored */States only, and deliberately no data-part on every internal node. Naming the parts would make the internal structure a public contract, and this library keeps one public surface, not two.
The material
Two rules decide what a surface may be, and they are not negotiable. A tint may be translucent — it has no hairline and no shadow, and letting the surface show through is the point. An object may not: it carries a hairline or a shadow, so it claims to rest on top of something, and you cannot see through a thing that is on top of another thing. A field is neither — it is a hole, and a hole is opaque because you read against it.
| Dark | Light | Where | |
|---|---|---|---|
| Shell | zinc-950/60 | zinc-100/60 | Only ever seen as a ring around a core |
| Core | zinc-900/85 | white/90 | What content sits on |
| Tooltip | zinc-900/95 | white/95 | Denser: it is small and lands on anything |
A single-layer panel is a core, not a shell. And the toast's core is opaque — the one exception in the library, because a card floats over another copy of itself.
Getting the styles in
Two paths, and they are both supported.
/* 1. Import the compiled stylesheet. Nothing else to configure. */
@import '@carabine/ui/styles.css';
/* 2. Or point your own Tailwind at the package, and let it generate them. */
@import 'tailwindcss';
@source '../node_modules/@carabine/ui/dist';Two Tailwind builds on one page
If you take the first path and run Tailwind yourself, you have two builds writing into @layer utilities. For anything they both define, the cascade falls back to document order — so whichever is imported second wins, and that is usually the library.
It matters in exactly one place: display. If a class of yours toggles it — hidden lg:flex on your own markup — the library's .hidden can land after your .lg\:flex and quietly win. Write those toggles in plain CSS, outside every layer, where nothing named .hidden can reach them.
What does not work is putting the library in a lower cascade layer: below base sits Tailwind's preflight, whose *, ::before, ::after { margin: 0; padding: 0 } then beats every px-3.5 in every component. The library ships without preflight on purpose — a package that resets a consumer's page is a package nobody keeps.
What not to override
Do not retune Tailwind's shared theme keys — --radius-*, --spacing — in a project that uses these components. Their radii are concentric by construction: an inner corner is the outer minus the bezel, and a theme that moves one of the two moves them out of agreement. It looks like a broken component, and it is a broken theme.
A consumer's theme is not a safe place to keep a component's arithmetic. Give the site its own token names instead.