@kairosis/eidos
v0.1.1
Published
Eidos — a React component library for dense, technical product UI.
Maintainers
Readme
Eidos
A React component library for dense, technical product UI — dev tools, data-heavy dashboards, and clean SaaS surfaces. Dark theme is the default; light is a first-class variant.
46 components, TypeScript throughout, CSS Modules over a Style Dictionary token pipeline.
Docs: kairosis.github.io/Eidos · Storybook
Install
npm install @kairosis/eidosreact and react-dom are peer dependencies. lucide-react ships as a dependency — it backs the Icon component.
Usage
Import the stylesheet once, at your app's entry point:
import "@kairosis/eidos/styles.css";Then use components:
import { Button, Card, Stat, Badge } from "@kairosis/eidos";
export function Overview() {
return (
<Card
title="api-gateway"
action={
<Badge tone="success" dot>
Healthy
</Badge>
}
>
<Stat label="Runs today" value="1,284" delta="+12% vs last week" deltaTone="positive" />
<Button icon="plus">Create project</Button>
</Card>
);
}Theming
Dark is the default. Switch to light by setting data-theme on <html> — or on any subtree root, to scope it:
<html data-theme="light"></html>Every token is a CSS custom property, so you can override any of them:
:root {
--primary: #8b5cf6;
}If you want the variables without the component styles, import the tokens alone:
import "@kairosis/eidos/tokens.css";useTheme
useTheme drives that attribute for you, and themeScript stops the flash of the wrong theme on first paint. Use both — a hook can only run after hydration, so on its own it will still let one frame of the wrong theme through.
// In <head>, before anything paints.
<script dangerouslySetInnerHTML={{ __html: themeScript }} />import { useTheme, SegmentedControl } from "@kairosis/eidos";
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<SegmentedControl
label="Theme"
options={["light", "dark", "system"]}
value={theme}
onChange={(next) => setTheme(next as Theme)}
/>
);
}| Returns | Type | |
| ---------- | ------------------------------- | ----------------------- |
| theme | "light" \| "dark" \| "system" | What the user chose |
| resolved | "light" \| "dark" | What's actually applied |
| setTheme | (theme: Theme) => void | |
Three states, not a boolean. "system" has to stay distinct from an explicit "dark", or someone who deliberately chose dark gets flipped to light when their OS switches at sunrise. resolved is "system" already resolved, for when you just need to know what's on screen.
Options: storageKey (default "eidos-theme"), persist (default true — turn it off if your app already owns theme state, e.g. next-themes), defaultTheme (default "system"), and element to scope the theme to a subtree instead of <html>.
There's no ThemeToggle component on purpose: the control's shape and placement is an app decision, and it's a few lines on top of the hook.
Tailwind v4
Eidos ships a Tailwind v4 theme generated from the same tokens, so utility classes and components draw from one source. In your CSS entry point:
@import "tailwindcss";
@import "@kairosis/eidos/styles.css"; /* tokens + component styles */
@import "@kairosis/eidos/tailwind.css"; /* maps the tokens into Tailwind's theme */Order matters, and styles.css (or tokens.css) is required — the theme references the Eidos custom properties rather than duplicating their values.
Tokens then reach you as ordinary utilities:
<div className="bg-bg-surface text-text-muted border-border rounded-md shadow-md p-4">
<span className="text-primary font-mono text-xs tracking-mono-label">LAST RUN</span>
<Button icon="plus">Create project</Button>
</div>| Tokens | Utilities |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Colors — --primary, --bg-surface, --text-muted, --tag-1…8 | bg-primary, bg-bg-surface, text-text-muted, bg-tag-3 |
| Radii, type, leading, tracking, easing | rounded-md, text-lg, font-mono, leading-tight, tracking-mono-label, ease-out |
| Shadows and focus rings | shadow-md, shadow-focus |
| Durations | duration-fast, duration-base, duration-slow |
Colour and shadow utilities are emitted as var() references, so they follow data-theme="light" at runtime instead of freezing the dark value. Everything else is theme-invariant and inlined as a literal.
Spacing is deliberately not remapped: Tailwind's default 4px scale is already the Eidos grid, so p-4 is 16px either way. Font weights match too, so font-medium is already 500.
Components
| Group | Components | | ---------- | ------------------------------------------------------------------------------------------------------------ | | Forms | Button, IconButton, Input, Textarea, Select, Autocomplete, Checkbox, Radio, Switch, SegmentedControl, Slider | | Display | Card, Badge, TagChip, Avatar, Icon, Alert, Kbd, CodeBlock, Skeleton, EmptyState, List, Accordion, Divider | | Typography | Heading, Text, MetaLabel, DataValue | | Navigation | Tabs, Sidebar, DropdownMenu, CommandPalette, Breadcrumbs, Pagination, Wizard, Tree | | Feedback | Dialog, Toast, Tooltip, Progress, Spinner | | Data | DataTable, Sparkline, BarChart, Stat | | Layout | AppShell |
AppShell
The application frame — fixed nav column, fixed top bar, scrolling content:
<AppShell sidebar={<Sidebar … />} header={<Breadcrumbs … />}>
<RunsPage />
</AppShell>Layout only: it owns the height/scroll contract (which Sidebar depends on — it's height: 100% and collapses without a sized flex parent), the min-width: 0 that stops a wide DataTable from shoving the page sideways, and 100dvh for mobile Safari. Below breakpoint (default 768px) the sidebar becomes a focus-trapped drawer.
It knows nothing about routing, page titles, or what goes in the header — that's the app's business.
Icons
Icon wraps Lucide and takes a kebab-case name:
<Icon name="git-branch" size={16} />
<Button icon="plus">Create project</Button>Names resolve against Lucide's icon map at runtime, so the whole set is available without extra imports. The trade-off is that bundlers can't tree-shake unused glyphs — if icon payload matters more to you than the string API, import Lucide components directly instead.
Use Lucide's current names: circle-check, not the deprecated check-circle-2; house, not home. An unknown name renders nothing and warns in development.
Development
npm install
npm run dev # Storybook on :6006
npm test # Vitest
npm run lint # ESLint + Stylelint
npm run lint:fix
npm run format # Prettier
npm run tokens # regenerate token CSS from tokens/
npm run build # tokens -> typecheck -> dist/
npm run typecheckCI (.github/workflows/ci.yml) runs format, lint, typecheck, test, build and Storybook on every push and PR. It also regenerates the tokens and fails if the committed CSS is stale — the generated files are committed so the package builds without the token step, and that guarantee is only true if it's enforced.
Tests
tests/ covers the behaviour the type system can't: that DataTable never reorders your rows, that Wizard refuses to skip ahead, that Autocomplete clamps its cursor when the filter narrows, that Dialog traps focus and restores it to the trigger.
tests/a11y.test.tsx runs axe over every component with real ARIA surface. Colour contrast is disabled there — jsdom has no layout or paint — and is covered instead by @storybook/addon-a11y, which runs axe in a real browser against every story.
Design tokens
tokens/ is the source of truth. Style Dictionary compiles the DTCG JSON into CSS custom properties:
tokens/primitives/palette.json private — never emitted
tokens/base/{typography,spacing,motion}.json -> src/styles/tokens/base.css (:root)
tokens/themes/dark.json -> src/styles/tokens/dark.css (:root — dark is the default)
tokens/themes/light.json -> src/styles/tokens/light.css ([data-theme="light"])
base + dark -> src/styles/tailwind.css (@theme inline)Edit the JSON, then run npm run tokens. The generated CSS is committed so the package builds without the token step — never edit it by hand.
Two layers. primitives/palette.json holds the raw ramps (blue.500, neutral.850). Semantic tokens reference them and never hold a hex of their own:
// tokens/themes/dark.json
"colors": {
"brand": { "primary": { "$value": "{palette.blue.500}" } },
"state": { "success": { "$value": "{palette.green.500}" } },
"surface": { "bg-surface": { "$value": "{palette.neutral.900}" } }
}Change the brand blue in one place and it reaches --primary, --primary-hover and --tag-1 in both themes. Both themes draw from one neutral ramp — light is dark inverted (--text is neutral.100 in dark, neutral.850 in light), so the two can't drift apart.
Grouping doesn't leak into the CSS. colors.brand.primary still emits --primary, not --colors-brand-primary: the name transform takes the token's leaf key. Those variable names are the public API — consumers override them — so the source can be reorganised without a breaking rename. Leaf keys are written out in full (bg-surface, not surface), so grepping the JSON for a variable name lands you on the token.
The palette is filtered out of every output, and references to it are inlined as literals. References to tokens that are emitted stay as var(): --focus-ring is defined once as 0 0 0 2px var(--bg), 0 0 0 4px var(--primary) and re-resolves per theme on its own.
Light overrides only what actually changes; everything else inherits from :root.
Layout
One component per folder, named in kebab-case, grouped by category. Stories live outside src/, so they are never part of the library's compile graph or the published package:
src/
components/
data/
bar-chart/
BarChart.tsx
BarChart.module.css
index.ts # the component's public API
data-table/ sparkline/ stat/
index.ts # group barrel — re-exports the folders above
display/ forms/ feedback/ navigation/ typography/
internal/
field/ # shared label/hint/error wrapper — not exported
lib/ # cx, options, tagColor, useClickOutside
styles/
popover.module.css # composed by Autocomplete, DropdownMenu, CommandPalette
tokens/ # generated — see below
index.ts # root barrel — re-exports the group barrels
stories/
data/BarChart.stories.tsx
display/ forms/ feedback/ navigation/ typography/Exports are declared once, in the component's own index.ts, and re-exported upward — nothing restates the list.
components/internal/ holds shared building blocks that aren't public, so the category folders contain only exported components. The root barrel deliberately skips it.
Styling
Components use CSS Modules, so hover, focus and checked states are real selectors (:hover, :focus-visible, :checked + …) rather than React state. Every value comes from a token — no hardcoded colors, spacing or timings.
Each component takes className and style for escape-hatch overrides.
Relationship to claude_design/
claude_design/ is the original design-system spec: guidelines, token definitions, and reference JSX. It's the source this library was ported from, and it stays as documentation. The shipping library is src/.
The port is faithful, with three deliberate departures:
- Icons load from
lucide-reactinstead of an injected CDN<script>, which broke under SSR, bundlers and offline use. - Styles are CSS Modules instead of inline
styleobjects — that's what makes real:hover/:focus-visiblepossible, and it scopes theinput[type="range"]rules the original leaked globally. - Keyboard access is fixed where the reference dropped it: selectable
Listrows andTreenodes are real buttons, and checkboxes, radios, switches and sliders now show focus rings.
