Headless primitives (@cascivo/core)
cascade is CSS-native, but the interactive behavior — focus, dismissal, keyboard
navigation, aria wiring — lives in a small, reusable headless layer in
@cascivo/core. You do not roll your own aria-* toggles or keyboard handlers
for menus, dialogs, and popovers: compose these primitives instead. They are
unstyled, signal-driven (no useState/useEffect), and SSR-safe.
State & reactivity primitives
The catalogue below is the a11y/behavior layer; this is the state layer. cascivo
components never use useState/useContext/useEffect — the signal is the state. When
you build on cascivo, reach for these, not the React hook you'd normally use:
Write state through a setter. In your own components, hold local state with
const [open, setOpen] = useSignalState(false)and writesetOpen(!open.value)— notopen.value = !open.value. Assigning to a signal a hook returned fails the React Compiler build and is reported byreact-hooks/immutability(on by default ineslint-plugin-react-hooks@7); the setter form passes both. Module-level signals can be assigned directly. For existing code that assigns, see USING-WITH-STRICT-ESLINT.md §1.
| You'd normally reach for… | Use instead | Why |
|---|---|---|
useState | useSignalState(initial) → [signal, setter] (local) / useComputed(fn) (derived) | The signal is the state; fine-grained updates, no subtree re-render. The setter keeps the React Compiler and react-hooks/immutability happy. |
| A controlled/uncontrolled prop wired to state | useControllableSignal({ value, defaultValue, onChange }) | Codifies the controlled↔uncontrolled bridge once, with no effect. |
useEffect for a DOM side effect | useSignalEffect(fn) | Runs on signal change, SSR-safe; useEffect is banned in cascivo components. |
useContext for shared state | A module-level signal imported anywhere | Signals are globally reactive — no provider or prop-drilling needed. |
| A cleanup registry to avoid leaks on route change | useScope() / createScope() | Disposes every owned effect on unmount; one scope per tenant/route boundary. |
| A reducer / explicit state machine | createMachine / useMachine | A minimal transition-table FSM backed by a signal. |
| A form library (react-hook-form, Formik) | createForm / useForm / <Form> / field() — @cascivo/react only (they build on the Field/Form components, which the copy-paste path vendors rather than imports) | Signal-backed store; sync/async + Standard Schema (zod/valibot) validation; optional validateOnChange validates on keystroke with zero re-renders. |
useEffect to toggle a .dark class | <ThemeProvider> + useTheme() / setTheme() | Persists + drives data-theme; pair with themePreloadScript() for SSR no-FOUC. |
Intercepting nav-item onClick to route client-side | setLinkComponent(YourLink) / getLinkComponent() | Config-driven nav (SideNav, ShellHeader, Header, Breadcrumb, Switcher, Dock, NavigationMenu) renders links through the registered component — a real router <Link>, so hover-preloading works and no click interception is needed. |
| Reading token names out of a CSS file | import type { CascivoToken, CascivoColorToken } from '@cascivo/tokens/tokens' | Generated union of every --cascivo-* property — visible in the type, no file lookup. |
When do I need useSignals()?
No consumer app runs the Babel signals transform, so a React component only re-renders on a signal write if something subscribed it. The rule is short:
You need
useSignals()only for a signal you did not get from a cascivo hook. That means exactly three things: a module-levelsignal(), a signal handed to you as a prop, andcurrentLocale()from@cascivo/i18n(a plain function, so it cannot self-subscribe). Call it as the component's first statement.
Everything a cascivo hook returns is already reactive on its own: useSignal,
useComputed, useSignalState, useControllableSignal, useDisclosure, useMediaQuery, useMachine,
useRovingFocus, useStreamBuffer, useScope, useTheme, useForm, useAnchorPosition
all call useSignals() for you.
That list is machine-checked, not a promise in prose. Every hook on it is rendered in a
transform-free React component, written to, and asserted to re-render
(packages/core/src/self-subscribe.test.tsx), and
scripts/checks/self-subscribe-parity.test.ts fails the build if this paragraph and the
code ever disagree in either direction. It is written this way because it was once prose:
the list claimed twelve hooks while the test covered three, useSignal and useComputed
were quietly not among the ten that worked, and an adopter shipped an entire dashboard whose
filters, sorts and toggles did nothing — with no error to explain it.
Symptom to recognize: handlers fire, your signal updates, the UI never moves. That is always a missing subscription, never a broken handler. See TROUBLESHOOTING.md.
One caveat on the wrappers. useSignals() starts tracking where it is called, so a
signal read that happens above your first useSignal/useComputed call is not tracked.
Call the hooks before reading signals — or just call useSignals() first, which is always
safe and is what cascivo's own components do.
Syncing a controlled prop into a signal
Preact runs effects synchronously on write, so where you read the mirrored signal decides which primitive is correct:
| The signal is read… | Use | Why |
|---|---|---|
in render (JSX, a data-* attribute, a derived value) | useControllableSignal({ value, defaultValue, onChange }) | The write must be synchronous or the component renders one tick stale. A render-phase write that only notifies the writing component's own subscription is a legal same-fiber render-phase update. |
only inside useSignalEffect | useEffectPropSignal(prop) | A synchronous write would run the effect body inside React's render phase — showModal(), listener registration against a pre-commit ref, a parent setState. useEffectPropSignal defers the mirror one microtask so the effect lands after the commit, where it was always meant to. |
Never hand-roll const s = useSignal(prop); s.value = prop for the second case: that is the
exact shape that shipped a render-phase showModal() in Modal, Sheet, Dropdown,
AlertDialog, HeaderPanel, CommandMenu and Presence.
For the full friction-to-primitive rationale with code — and why the generic React/Tailwind patterns an LLM defaults to are wrong here — see ENTERPRISE-READINESS.md.
Catalogue
| Primitive | What it does |
|---|---|
useId(prefix?) | Stable, SSR-safe, colon-stripped id for aria wiring and CSS anchor names. One per instance — never hardcode aria ids or use Math.random(). |
DismissableLayer | Dismisses on an outside pointer press or Escape. Nested layers form a stack — only the top-most responds (a dropdown inside a modal dismisses top-first). |
FocusScope | Focus trap with trapped, restoreFocus, autoFocus. Cycles Tab/Shift+Tab within its subtree. |
useRovingFocus | Roving tabindex for toolbars/lists. Arrow keys move focus (Home/End jump, loop wraps); only the active item is tabbable. Spread getItemProps(i). |
useTypeahead | Type-to-select. Accumulates printable keypresses into a query, resets after inactivity, and calls onMatch(query) so you focus the matching item. |
useDisclosure | Controllable open/close state (isOpen, open, close, toggle). |
useAnchorPosition / computePosition | Position a floating element against an anchor. |
Portal | Render into document.body (or a target) while keeping React context. |
Presence | Keep an element mounted through its exit transition. |
useScrollLock | Lock body scroll while an overlay is open. |
VisuallyHidden | Visually hide content while keeping it in the a11y tree. |
Slot / composeRefs / mergeProps | Merge props/refs when forwarding to a child element. |
focusElement | Focus whatever a ref actually points at; returns whether it worked. Use it instead of ref.current?.focus() whenever the ref was attached to a consumer-supplied element (a cloned trigger), because a ref on a function component resolves to the DOM node under React 19 but to the component instance under preact/compat — so .focus() is undefined there and throws. Safe (and equivalent) on a ref you attached to your own intrinsic element. |
normalizeTone / Tone / ToneInput | The catalog's one severity vocabulary — neutral | info | success | warning | danger. Badge, Tag, Status and Notification all accept it, plus their historical spellings (destructive/error → danger, default → neutral) which normalizeTone resolves. Use it to map one domain enum onto every display component without a per-component lookup table. |
normalizeProgress / Progress / ProgressInput | The catalog's one sequence vocabulary — pending | active | complete | error. Steps and Timeline both accept it, plus Timeline's current / upcoming aliases, which normalizeProgress resolves. |
sentimentOf / Trend / GoodDirection / Sentiment | Splits which way a metric moved (the arrow) from whether that is welcome (the colour). Stat and Kpi both hard-coded up-is-green, so a deploy console's error-rate and latency tiles rendered their worst news in green, and negating the delta to fix the colour also reversed the arrow. Pass goodDirection="down" for errors/latency/cost/churn, "neutral" where neither direction is inherently good. |
ThemeProvider / useTheme / setTheme / themeSignal / applyTheme / themePreloadScript | The theme runtime: persists the choice, drives data-theme + color-scheme, and renders with no FOUC under SSR. In @cascivo/core, so the copy-paste path can reach it — @cascivo/react re-exports the same names, so both install paths use identical code. useTheme() returns a TUPLE [name, setTheme], not { theme, setTheme }. Outside React (an imperative shell, a pre-hydration script) use applyTheme(theme, target?). See THEMING.md. |
persistedSignal | A signal backed by localStorage (or any StorageDriver), SSR-safe: with no window the driver is a no-op and the signal still works on the server. Survives reload and syncs across tabs. Pass parse: (raw) => T to check what it reads back (on load and from other tabs): a stored value that throws is dropped and the signal keeps its value — storage is written by older code and by hand, so treat it like any payload. Lives here rather than in @cascivo/storage because the theme runtime above needs it and @cascivo/storage depends on this package — @cascivo/storage re-exports it unchanged. |
localStorageDriver / memoryDriver / StorageDriver | Storage backends for persistedSignal. memoryDriver() is the SSR/test fallback. indexedDBDriver stays in @cascivo/storage (it is async and not needed by the theme runtime). |
setLinkComponent / getLinkComponent | Register the component config-driven nav renders links through. Call setLinkComponent(...) once at app start so SideNav/ShellHeader/Header/Breadcrumb/Switcher/Dock/NavigationMenu render your framework's router <Link> (preserving href, aria-current, active data-state), instead of a plain <a>. The Link must forward ref to its anchor for roving-focus navs. Re-exported from @cascivo/react (Path B) as well as @cascivo/core (Path A). |
@cascivo/core/pure — the server-safe subset
@cascivo/core's main entry is a single bundled chunk carrying a 'use client' banner. That
banner is load-bearing (without it Next.js treats every hook and Portal/Presence as a
Server Component), but it also means everything imported from @cascivo/core sits behind
a client boundary — including helpers that need no browser at all. A component that renders
on the server and imports cn from there fails RSC prerendering with Attempted to call cn() from the server but cn is on the client.
@cascivo/core/pure is the same helpers built without the banner:
import { cn, Slot, normalizeTone, useId } from '@cascivo/core/pure'
Exports cn, composeRefs, mergeProps, Slot, normalizeTone, normalizeProgress,
useId, and their types. Every one is transitively free of client-only React APIs — useId
included, because React exports it under the react-server condition.
When to use which. Reach for /pure only from a component that must render on the
server — exactly the set clientJs: 'none' names in the manifests. Everything else keeps its
single @cascivo/core import, which re-exports all of these anyway. Type-only imports
never need it: import type { ToneInput } from '@cascivo/core' is erased at compile time,
so it creates no runtime edge, and routing types through two specifiers makes the published
.d.ts alias them (ToneInput$1).
Adding to this subpath is a packaging decision, not a convenience — anything unexportable from a Server Component belongs in the main entry.
The boundary is transitive, and that is the part people miss. It is not enough for your
own imports to be clean: a module you import must also be clean. @cascivo/i18n used to take
signal from @cascivo/core, so Label, AvatarGroup and InlineLoading — all
clientJs: 'none', all importing only /pure themselves — still crashed RSC with Attempted to call signal() from the server, two hops out. scripts/checks/rsc-boundary.test.ts walks
the published module graph and fails on any such path, so you get a build error naming the
hop instead of a framework error naming the symptom.
Rendering a client component from a Server Component stays legal and is not flagged —
Button renders Spinner, Field renders Label. What breaks is calling a function that
lives behind the boundary, or reading a property off it.
A default i18n label makes a component a client component
t(builtin.…) reads the i18n locale and catalog signals so labels re-render when either
changes at runtime. Reading a signal in render means the component must call useSignals(),
and that means 'use client'. So:
Any component that resolves a user-visible default string through
t()is a client component, even when its markup is otherwise entirely static.
Six shipped components are client components for this reason alone — Spinner, Breadcrumb,
Header, SkipNav, QrCode, Switcher. Their markup is fully server-rendered; the
boundary buys only live re-labelling on a locale change. They declare
clientJs: 'enhancement' accordingly.
This is the price of provider-free runtime i18n, and it is deliberate: the alternative —
resolving defaults without tracking — would silently stop locale switching from updating
mounted components, which is a far worse failure than a boundary on a Spinner. Know the
rule when you author: a component with no default strings can be clientJs: 'none'; add one
t(builtin.…) call and it cannot.
Router integration — client-side nav links
cascivo's config-driven nav components render plain <a href> by default. To make
them navigate through your framework's router (keeping client-side transitions and
hover-preloading) register a link component once at startup — a module singleton, so
no provider or per-item wiring:
// TanStack Router — its Link takes `to`, so map href → to and spread the rest:
import { setLinkComponent, type LinkComponentProps } from '@cascivo/react'
import { Link } from '@tanstack/react-router'
setLinkComponent(({ href, ...rest }: LinkComponentProps) => <Link to={href} {...rest} />)
// Next.js — its Link takes `href` directly:
import Link from 'next/link'
setLinkComponent(Link)
LinkComponentProps (re-exported from @cascivo/react, also in @cascivo/core) is the
computed prop bag — href, className, onClick, aria-current, active data-state,
tabIndex, and any data-*. Spread it whole so active styling and a11y carry over; the
onClick only preventDefaults a disabled item, so a router keeps middle-click /
open-in-new-tab.
On the prebuilt path (Path B) import setLinkComponent/getLinkComponent/LinkComponentProps
from @cascivo/react — they are re-exported there for exactly this reason. Do not
import them from @cascivo/core on Path B: @cascivo/core is only a transitive dependency
of @cascivo/react, so import … from '@cascivo/core' is a phantom-dependency error
(MODULE_NOT_FOUND under pnpm's strict node_modules, and a lint error under
no-extraneous-dependencies) unless you add @cascivo/core to your own package.json. The
@cascivo/core import is correct only on the copy-paste path (Path A), where cascivo init
installs @cascivo/core as a direct dependency; both resolve the same singleton.
The registered component receives the full computed prop bag (href, className,
aria-current, data-state, onClick, …), so active styling and accessibility carry
over unchanged. For a one-off custom item, SideNavItem.render({ children, linkProps })
gives you the same bag plus the default icon/label node.
setLinkComponent covers config-driven navs — not links you write yourself
It is deliberately scoped to components that render the <a> for you. A link you place
in page content (a project name in a card title, a branch in a table cell) is styled with
asChild instead, which puts cascivo's styling on your router's element:
<Link asChild><RouterLink to="/projects/alpha">alpha</RouterLink></Link>
<Button asChild><RouterLink to="/projects/new">New Project</RouterLink></Button>
A bare <Link href="/x"> in a routed app is a full page reload, and copying cascivo's link
CSS into your own layer drifts on every release — override --cascivo-link-color instead.
Full guide, including every asChild-capable component:
USING-WITH-A-ROUTER.md.
Recipe: an accessible menu from primitives
The shipped Menu composes usePopover + useTypeahead + DOM-resolved roving
focus. To build your own, the same three concerns compose directly:
'use client'
import { useId, useTypeahead, useSignalState } from '@cascivo/core'
export function Menu({ items }: { items: { label: string; onSelect: () => void }[] }) {
const [open, setOpen] = useSignalState(false)
const menuId = useId('menu')
// Resolve enabled items from the DOM so disabled rows/separators are skipped.
const enabled = (panel: HTMLElement) =>
Array.from(panel.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])'))
const typeahead = useTypeahead({
onMatch: (q) => {
const panel = document.getElementById(menuId)
if (!panel) return
enabled(panel).find((el) => (el.textContent ?? '').trim().toLowerCase().startsWith(q))?.focus()
},
})
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const panel = e.currentTarget
const list = enabled(panel)
const i = list.indexOf(document.activeElement as HTMLElement)
if (e.key === 'ArrowDown') { e.preventDefault(); list[i < 0 ? 0 : (i + 1) % list.length]?.focus() }
else if (e.key === 'ArrowUp') { e.preventDefault(); list[i < 0 ? list.length - 1 : (i - 1 + list.length) % list.length]?.focus() }
else if (e.key === 'Home') { e.preventDefault(); list[0]?.focus() }
else if (e.key === 'End') { e.preventDefault(); list.at(-1)?.focus() }
else typeahead.onKeyDown(e)
}
return (
<>
<button type="button" aria-haspopup="menu" aria-expanded={open.value} onClick={() => setOpen(!open.value)}>
Actions
</button>
{open.value && (
<div id={menuId} role="menu" onKeyDown={onKeyDown}>
{items.map((item) => (
<div
key={item.label}
role="menuitem"
tabIndex={0}
onClick={item.onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); item.onSelect() }
}}
>
{item.label}
</div>
))}
</div>
)}
</>
)
}
Wrap the panel in <DismissableLayer onDismiss={() => setOpen(false)}> to close
on outside click / Escape, and add <FocusScope trapped> for a modal menu. Every
piece is independently testable and shared across components — no per-component aria
or keyboard reimplementation.
Rules of thumb
- Ids: always
useId(). A hardcoded literal breaks the moment a component renders twice on a page;Math.random()breaks SSR hydration. - Dismissal:
DismissableLayer, not a rawdocumentlistener — it also gets the nested-layer, top-first case right. - Keyboard:
useRovingFocusfor arrow navigation,useTypeaheadfor type-to-select. - Enforced by
pnpm primitives:check(seeCLAUDE.mdauthoring rules).
As Markdown: /docs/headless.md