Building components
Build components from Onset tokens, primitives, and state hooks.
Use tokens instead of hardcoded values
Never hardcode a color or a pixel spacing. Every visual value in Onset comes from a --m-* CSS custom property. Custom components must read from the same tokens, or they will not follow theme changes.
/* Your custom component styles */
.my-card {
background: var(--m-color-surface-low);
border: 1px solid var(--m-color-border);
padding: var(--m-space-4);
border-radius: var(--m-radius-md);
color: var(--m-color-fg);
}/* Broken: hardcoded values drift from the theme */
.my-card {
background: #1a1a2e;
border: 1px solid #2a2a4a;
padding: 16px;
border-radius: 8px;
color: #e0e0e0;
}Use semantic token names, not literal values: --m-color-fg-strong for prominent text, --m-color-fg-dim for secondary text, --m-color-fg-faint for tertiary. Use --m-space-2 for 8px padding, --m-space-4 for 16px. The component then follows every theme change without further work.
Use Onset primitives
Use Box as your base container
Box accepts token-aware style props that resolve through the theme. Use it as the container for custom components instead of writing CSS.
import { Box, Stack, Text } from "@solarius/onset";
function MyCard({ title, children }) {
return (
<Box bg="surface-low" border rounded="md" p="4">
<Stack gap="3">
<Text size="lg" weight="semibold">
{title}
</Text>
{children}
</Stack>
</Box>
);
}Use Control for custom form inputs
Control provides the shared border, height, focus, disabled, and invalid styles used by Onset inputs. Wrap your custom element in Control and apply controlInputClass to the inner input.
import { Control, controlInputClass } from "@solarius/onset";
function MyDurationInput() {
return (
<Control suffix="seconds" size="md">
<input className={controlInputClass} type="number" placeholder="30" />
</Control>
);
}Use Surface/Panel for structured content
Surface applies background, border, radius, and shadow through its elevation prop. PanelHeader, PanelBody, and PanelFooter provide the header/body/footer structure used by Card, Modal, and Drawer.
Use Prose for markdown content
If your component renders markdown or CMS content, wrap it in Prose (or apply proseClass to the container). This applies Onset's typography to headings, lists, code blocks, tables, and blockquotes.
Use cx() for CSS module classes
cx joins class names and ignores falsy values. Use it (or your own equivalent) to combine CSS module classes conditionally.
import { cx } from "@solarius/onset";
import styles from "./MyComponent.module.css";
function MyComponent({ size, variant }) {
return (
<div
className={cx(
styles.root,
styles[size],
variant !== "default" && styles[variant],
)}
>
...
</div>
);
}Follow the one-component-one-CSS-module convention
Each Onset component lives in a ComponentName.tsx file with a sibling ComponentName.module.css. The module exports scoped class names that cannot collide with other components or consumer styles. Follow the same pattern for your custom components.
Prefer existing components over raw primitives
Check whether a composition of existing Onset components does what you need before building from primitives. For example, use Select + List instead of implementing a select from Popover + Control. The built-in components handle keyboard navigation, focus management, and dismissal behavior.
Controlled & uncontrolled
Stateful components expose the controlled and default props listed in their references. useControllableState implements controlled and internal state for custom components.
import { useControllableState } from "@solarius/onset";
interface MyToggleProps {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
}
function MyToggle({
pressed: controlledPressed,
defaultPressed = false,
onPressedChange,
}: MyToggleProps) {
const [pressed, setPressed] = useControllableState({
value: controlledPressed,
defaultValue: defaultPressed,
onChange: onPressedChange,
});
return (
<button
role="switch"
aria-pressed={pressed}
onClick={() => setPressed(!pressed)}
>
{pressed ? "On" : "Off"}
</button>
);
}
<MyToggle defaultPressed />;
const [on, setOn] = useState(false);
<MyToggle pressed={on} onPressedChange={setOn} />;useControllableState returns a tuple [value, setValue]. When the consumer passes value, the hook is controlled: it returns the consumer's value and calls onChange on updates. When value is undefined, the hook is uncontrolled: it manages its own state starting from defaultValue, and still calls onChange so the consumer can observe changes without controlling them.
A complete example
A searchable select built from Onset primitives. It uses Control for the field shell, Popover for the dropdown positioning, Input for the search field, List and ListItem for the options, and useControllableState for the controlled/uncontrolled pattern.
import {
Control,
Popover,
Surface,
Input,
List,
ListItem,
Text,
controlInputClass,
useControllableState,
} from "@solarius/onset";
import { useRef, useState, useMemo } from "react";
interface Option {
value: string;
label: string;
}
interface SearchableSelectProps {
options: Option[];
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
placeholder?: string;
}
export function SearchableSelect({
options,
value: controlledValue,
defaultValue = "",
onChange,
placeholder = "Select...",
}: SearchableSelectProps) {
const anchorRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [selected, setSelected] = useControllableState({
value: controlledValue,
defaultValue,
onChange,
});
const selectedLabel = options.find((o) => o.value === selected)?.label;
const filtered = useMemo(
() =>
search
? options.filter((o) =>
o.label.toLowerCase().includes(search.toLowerCase()),
)
: options,
[options, search],
);
return (
<>
<Control
size="md"
open={open}
onClick={() => setOpen(true)}
style={{ cursor: "pointer" }}
>
<div ref={anchorRef} className={controlInputClass}>
{selectedLabel ?? placeholder}
</div>
</Control>
<Popover
open={open}
anchorRef={anchorRef}
placement="bottom-start"
offset={4}
matchWidth
>
<Surface elevation="overlay" w={240}>
<div style={{ padding: "var(--m-space-1_5)" }}>
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
<List>
{filtered.map((opt) => (
<ListItem
key={opt.value}
selected={opt.value === selected}
onClick={() => {
setSelected(opt.value);
setOpen(false);
setSearch("");
}}
>
{opt.label}
</ListItem>
))}
{filtered.length === 0 ? (
<Text
size="sm"
color="fg-faint"
style={{ padding: "var(--m-space-2)" }}
>
No results
</Text>
) : null}
</List>
</Surface>
</Popover>
</>
);
}Control provides field styling. Surface provides the panel appearance. Popover positions the panel. useControllableState manages selection.
The component reads theme tokens for colors and radius. Changes to ThemeProvider update those styles.