A React design system with a tactile look: what can be pressed (buttons, selects, chips) stands out in relief, fields where a value goes are carved into the page, and the state of each control reads from its shape as much as from its color. Built on Base UI (unstyled, accessible primitives), written in TypeScript, styled with Sass CSS Modules and CSS custom-property tokens, and documented with Storybook.
- Accessible by construction. Behaviour and keyboard handling come from Base UI. Text reaches 4.5:1 contrast in both themes, selection never relies on color alone, and every accessibility contract has a test.
- Light and dark, following the OS or set by the app, with no flash on first paint.
- Consistent props everywhere:
tone,variantandsizemean the same on every component. - Opinionated on purpose. Components take no
className,styleorrender: customisation goes through variants, sizes and tokens, so an app cannot drift from the kit. - Controlled or uncontrolled, for every stateful component.
- Server Components friendly, dot notation included (
<Card.Body>).
Early stage (0.1.x): the API can still change between versions, and the package is not published to npm: package.json is marked private to keep it that way for now. The planned work is in docs/backlog.md.
Relievo is not on npm. Build a tarball from a clone, then install it in your app:
# in the Relievo clone
pnpm install
pnpm build
pnpm pack --pack-destination /path/to/your-app/vendor
# in your app
pnpm add ./vendor/relievo-0.1.0.tgz @phosphor-icons/reactThe tarball is named after the version in package.json. Build again and reinstall it to pick up a change.
react and react-dom (19+) are peer dependencies, like @phosphor-icons/react, the icon set used by the components and recommended for yours.
Import the stylesheet once, at the root of your app:
import { Button } from 'relievo';
import 'relievo/styles.css';
<Button tone="accent" size="md">Save</Button>;See Theming for light and dark mode and Router links to use your router's link component.
To browse every component with its props, open the Storybook, or run it locally (pnpm storybook, see Development). The "All components" story of the Overview shows them together on a realistic screen.
| Component | Purpose |
|---|---|
Box |
Generic container: padding, margin, optional border, element (as) |
Stack |
Children in a column or a row: gap, alignment, separators, wrap, element (as, lists included) |
Button |
Actions: solid or link variant, tone (neutral, accent, status tones); href renders it as a link; startIcon / endIcon |
ButtonGroup |
Related buttons on a single line, with a fixed gap |
Card |
A flat panel grouping related content, with an optional tone (and its corner icon) and an outline variant: Card.Header (Card.Title, Card.Description), Card.Body, Card.Footer |
Alert |
A message in the flow of the page (tone, title, an icon, an optional close button) |
Link |
A link in running text: inherits the type around it, tone, external |
Checkbox |
A single yes / no choice, with a mixed state |
Input |
Single-line text field with label, helper text, error, icons, prefix / suffix, an end action (Input.Action: clear, show password) or a button beside it |
Textarea |
Multi-line text field, the same carved field as Input |
Menu |
A neutral button opening a list of actions: groups, separators, submenus, checkable items |
Pagination |
Previous / next and page numbers, as buttons or links |
Chip |
Tags and statuses (static), or selectable toggles |
Chip.Group |
Filters: several choices among chips |
Segmented |
A single choice among a few short options (radio group) |
Tabs |
A bar of tabs over the content they show: Tabs.List, Tabs.Item (icon), Tabs.Panel (plain or framed) |
ComboBox |
A choice among a long list you type into to filter it, or several with multiple (removable chips), its options given or loaded from an API (loadOptions): a carved field with a round accent button that opens a floating list |
Collapsible |
A section whose content (children) shows or hides on click, under its label |
Spinner |
An action in progress, as a spinning ring; drop it anywhere an icon is expected, such as startIcon |
ThemeProvider, useTheme |
Light / dark / system mode |
RelievoProvider |
App-level configuration (router link component) |
Components do not accept className, style or Base UI's render prop: their look is owned by the design system and changed through variants, sizes and tokens only. Use href to render a Button as a link.
Every component follows the same conventions, listed in AGENTS.md (also the reference for AI coding agents).
The kit uses Geist through @fontsource-variable/geist, a dependency imported by the package entry. Your bundler emits the font files (about 30 KB for Latin, all weights in one variable file); nothing to configure. Until the font loads, text falls back to the system font.
Components that take an href render a native <a> by default. To use your router's link, pass it once to RelievoProvider at the app root. It must forward its props, including className, to the rendered <a>.
// Next.js (App Router): app/providers.tsx
'use client';
import Link from 'next/link';
import { RelievoProvider } from 'relievo';
export function Providers({ children }: { children: React.ReactNode }) {
return <RelievoProvider linkComponent={Link}>{children}</RelievoProvider>;
}For routers whose link takes another prop than href (React Router's to), pass a small adapter:
const RouterLink = ({ href, ...props }: LinkComponentProps) => <Link to={href} {...props} />;The components are client components, and can be imported from Server Components, dot notation included (<Card.Body>).
Wrap your app in ThemeProvider. defaultMode is "system" (follow the OS preference, the default), "light" or "dark":
import { ThemeProvider } from 'relievo';
<ThemeProvider defaultMode="system">
<App />
</ThemeProvider>;To own the mode yourself (to persist it, for example), control it with mode and onModeChange. Read the stored value in an effect rather than in the initial state: reading it eagerly would return a different mode on the server (no localStorage) and on the client's first render, which React flags as a hydration mismatch wherever the mode reaches the rendered output (a menu label showing the mode, for example):
const [mode, setMode] = useState<ThemeMode>('system');
useEffect(() => {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark' || stored === 'system') setMode(stored);
}, []);
<ThemeProvider
mode={mode}
onModeChange={(next) => {
localStorage.setItem('theme', next);
setMode(next);
}}
>
<App />
</ThemeProvider>;That still flashes the OS default for a moment before the effect runs. To avoid it, render ThemeScript in <head>, ahead of the app: it is a blocking script, so it sets data-theme before the first paint, independently of when React hydrates:
import { ThemeScript } from 'relievo';
<head>
<ThemeScript storageKey="theme" />
</head>;storageKey must match what onModeChange writes above (it defaults to 'theme'). It only ever sets data-theme, never removes it: for system, or when nothing is stored yet, the server already renders with no data-theme, which is already correct.
Read or change the mode anywhere inside it with useTheme():
const { mode, resolvedMode, setMode } = useTheme();The provider sets data-theme on <html>, so portalled popups are themed too. A ComboBox or Menu list opened inside a section forced with data-theme (below) takes that section's theme. In system mode it removes the attribute and the CSS follows prefers-color-scheme. Render a single provider at the app root. To force a theme for one section, set the attribute directly:
<section data-theme="dark">Always dark here</section>In Storybook, switch modes from the toolbar.
| Command | Description |
|---|---|
pnpm storybook |
Start Storybook at http://localhost:6006 |
pnpm build-storybook |
Build a static Storybook to storybook-static |
pnpm build |
Build the library to dist/ (ESM + types + CSS) |
pnpm typecheck |
Type-check the project |
pnpm lint |
Lint with oxlint |
pnpm format |
Format the code with oxfmt (format:check only checks) |
pnpm test |
Run the unit tests (Vitest + Testing Library) |
pnpm test:watch |
Run the unit tests in watch mode |
The project layout:
src/
index.tsx # package entry: re-exports client.ts, rebuilds compound components
client.ts # the kit (built as a 'use client' module)
components/<Name>/ # component, Sass CSS Module (*.module.scss), stories, tests, index
theme/ # ThemeProvider and useTheme
provider/ # RelievoProvider (router link)
internal/ # shared internals (IconSlot, tone icons, useInheritedTheme)
utils/ # useControllableState
styles/tokens.scss # design tokens (--rv-*), light/dark palettes, relief tokens
styles/_*.scss # shared Sass mixins (control sizes, fields, floating lists, icons, typography, a11y)
styles/global.scss # global element styles (html font size, body colors, scroll behavior)
overview/ # a story using every component together
test/ # test setup and mocks
docs/
contributing.md # recipes for contributors (visual captures)
releasing.md # versioning policy and how to publish a release
theming.md # plan for app-chosen colors
backlog.md # planned work and open points
See CONTRIBUTING.md: how to set up, what a change should come with, and when to open an issue before a pull request. The conventions are in AGENTS.md.
MIT © Nicolas Challeil (aka STuFF)
