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 it

There 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.

ComponentThe propWhat it holds
ToasttonesPer state: an icon, a colour per theme, three strip shades, an action
ButtonvariantsPer variant: background, colour, ring, hover, sweep — per theme
SwitchskinTrack, thumb and rings — merged over the theme’s, one field at a time
Hover listbarOne plain CSS string: the colour of the sliding bar
Progresscolor / trackOne colour, or three brightest first
Color pickerringsThe 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.

DarkLightWhere
Shellzinc-950/60zinc-100/60Only ever seen as a ring around a core
Corezinc-900/85white/90What content sits on
Tooltipzinc-900/95white/95Denser: 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.