Philosophy
Every architectural decision in Mantle traces back to a simple premise: start with the web platform, then layer on just enough abstraction to make it great. These principles guide what we build, how we build it, and (just as importantly) what we choose not to build. They apply to the whole library: components, hooks, utilities, theming, and the docs themselves.
Semantic HTML, then everything else
The right HTML element is always the starting point. A Button
renders a <button>, not a styled <div> with click handlers.
Checkbox is a real <input type="checkbox"> styled entirely with
CSS, so it participates in native forms without wrapper elements or a scripted check icon.
DescriptionList is real <dl>/<dt>/<dd>, and
Kbd is a real <kbd>. From that foundation, Mantle progressively
enhances. A button is still a button, but with loading states, icon slots, and variant
styling layered on top. The underlying element always does the heavy lifting.
Enhancement is deliberate, and so is degradation. Accordion
keeps collapsed content in the DOM with hidden="until-found", so find-in-page can
reveal matches inside closed sections; its height animation rides CSS interpolate-size
and snaps in engines without it. SkipToMainLink
is a plain href="#main" anchor that works with JavaScript disabled, then enhances with
history.replaceState and scroll-free focus. The scroll fade
utilities are pure CSS scroll-driven animations: where a browser lacks support, the fade
is absent and content is never clipped. Every enhancement names its fallback.
This extends to how components are named. Parts take the web platform's vocabulary for
what they actually render: a List contains Items because <li> is
literally a list item, a Table contains Rows because that's what
<tr> is, and every compound's outermost part is Root. Props read as the semantics
they emit, so the API tells the truth about the markup: List.Item's current prop
sets aria-current. And where WAI-ARIA offers competing patterns, the component makes the
choice for you: List is a semantic role="list" so every item keeps its natural tab
stop, while SelectableList is an APG grid because
selectable rows carrying real controls require it. You never pick a role; you pick an
intent.
Because names promise behavior, ARIA's pattern words are earned rather than borrowed. A
Menu owes you roving focus, typeahead, Escape to dismiss, and a single tab stop, so
only a real role="menu" component gets that name. DropdownMenu
has it; a <ul> of navigation links doesn't, however many other libraries call that
markup a menu. The same goes for Listbox, Tab, Tree, Grid, and Combobox. When a
name and the markup disagree, we rename the part. We never add the role to justify the
name; that's how a perfectly good list of links loses its tab order. Mechanics are worth
porting from the ecosystem; vocabulary is re-derived from what the component emits.
The right foundation, per problem
Mantle isn't "built on Radix." Where the platform is enough, components are native:
Accordion is hand-rolled precisely because a headless collapsible would lose the
find-in-page behavior above, and Field is real
<fieldset>/<legend>/<label> elements with Mantle-owned wiring. Where the behavior
is genuinely hard, Mantle wraps the best engine for that specific problem:
Radix for Dialog,
Ariakit for Combobox,
Headless UI for RadioGroup, cmdk for
Command, TanStack Table for DataTable,
sonner (mounted unstyled) for Toast.
You can't tell which is which from the outside, and that's the point. Every foundation
is normalized behind one house API: flat compound namespaces, asChild polymorphism, a
data-slot attribute on every rendered part, and consumer className merged last with
cx. A vendor is a starting point, not a ceiling:
Switch adds the read-only state Radix lacks, the dialog primitive
strips Radix's dangling aria-describedby when no description is mounted, and
Slot re-implements Radix's className merge so your classes beat the
component's. If you understand how one Mantle component works, you can predict how the
rest behave. Learn the system once; apply it everywhere.
One component per intent
Every public component answers a distinct question. "Click an item to go somewhere or do something" is List; "check items to select them" is SelectableList. "Pick one of a few known options" is Select; "type to filter many" is Combobox; "pick several" is MultiSelect. The JSDoc on each names its siblings, so the boundary ships with the component. When two designs answer the same question, they become one component: a variant is a prop or a sibling part (RadioGroup's card and segmented arrangements are parts, not new components), never a second thing to choose between.
The same discipline keeps shared machinery private. One unexported dialog primitive backs Dialog, Sheet, and AlertDialog; one unexported list primitive backs both list components. Publishing a primitive later is an additive, non-breaking change; un-shipping one is a breaking change forever. Refusing surface applies to props, too: when DataTable needed empty states and pagination, the answer was documented recipes built from existing parts (Empty drops straight into a table's empty row), not a new built-in API. The gap was documentation, not primitives. Mantle ships the smallest surface that solves the problem and grows it only when real usage demands: the API you learn is the API that stays.
Composition over configuration
Components compose with each other and with your code. The asChild pattern, powered by
Slot, swaps the rendered element entirely, so Mantle never forces
a DOM structure on you: internal navigation is <Anchor asChild><Link to="…" /></Anchor>,
not an href prop with router flags. Configuration props handle the common case;
composition handles everything else. Never a prop bag: a component doesn't forward
contentProps-style objects to elements it renders internally, because needing to reach
an internal element means the component is a compound and that element is a part.
Composition is also how new components get built:
SplitButton is nothing but Button, IconButton, and
DropdownMenu with the seams styled.
Multi-part components ship as flat, single-level namespaces (Dialog.Root,
Dialog.Content, never Command.Dialog.Root) so the whole API surfaces in one
autocomplete. That was a recorded trade: we gave up per-file tree-shaking of compound
internals for discoverability, and kept leaf components like
Button and Icon as standalone exports so they
still shake cleanly.
The exception is deliberate: a component takes a data prop instead of children only
when its behavior must reach items that aren't rendered yet.
DataTable takes data and
SelectableList takes options because filtering, "select
all", and virtualization operate over the whole collection (including rows that are
windowed out of the DOM), which no children-based API can see. Even then, rendering
stays composable: a render-prop draws each row, and the data stays a thin description (a
value, its text, a disabled flag). Membership is data when behavior outlives mounting;
presentation is always composition.
Make invalid states unrepresentable
Where a contract can live in the type system, it does.
Accordion's type prop is a discriminated union that retypes
value and onValueChange as string or string[] per mode.
SelectableList requires labelText the moment label
becomes a rich ReactNode, so a filter that would silently stop matching becomes a compile
error instead. And Badge's palette tables are exhaustiveness-checked
with satisfies Record<Color, string> against the color vocabulary, so a
new color can't ship half-styled.
What the compiler can't catch fails loudly at runtime. Compound parts rendered outside
their Root (Choice's, QrCode's,
SelectableList's) throw a descriptive invariant instead of rendering an inert shell.
Utilities treat errors as control flow: copyToClipboard throws when both clipboard
strategies fail rather than no-oping. A silent fallback is a bug you find in production;
a loud failure is one you fix in development.
Pure cores, thin bindings
Logic you can test without a framework is logic you can trust. Mantle's utilities are
framework-free cores with React bound on at the edge: in-view wraps a
raw IntersectionObserver and useInView is its hook,
compose-refs ships composeRefs beside useComposedRefs, and
useCopyToClipboard returns the same plain async function everything else
uses. The vocabulary utilities, color and sorting,
derive their union types, type guards, and helpers from single const arrays, so runtime
values and TypeScript types can't drift apart. Components obey the same layering:
SelectableList's filtering and selection math are exported
pure functions, tested apart from rendering.
Accessibility is engineered, then given away
The promise isn't that semantic markup makes accessibility free. The promise is that
Mantle does the engineering so you get it free. Field generates
control, description, and error ids and flows them through context, so htmlFor,
aria-describedby, and aria-errormessage always resolve without hand-threading. Its
help affordance is a Popover rather than a tooltip, so touch
users can reach it. List's disabled asChild items swallow clicks
because assistive technology dispatches clicks without hit testing. Virtualized rows
announce aria-posinset and aria-setsize, so a windowed list still reads as the whole
collection. IconButton makes its screen-reader label a
required prop. And motion defaults are conservative:
usePrefersReducedMotion returns true on the server and in the hydration
render, so nothing animates until the user's real preference is known.
The per-component keyboard and ARIA contracts, required app setup, and testing checklists live on the Accessibility page. This page owns only the stance: accessibility here is designed and reviewed like any other behavior, never inherited on faith from a vendor.
The first paint is part of the API
Mantle assumes a server, and if it renders wrong for one frame, that's a bug. The
theme preference persists in a cookie, not just localStorage, so SSR
renders the correct <html> class; a blocking inline script (written as a plain
TypeScript function and stringified into the <head>) resolves the cookie and OS
preferences before first paint, and theme changes sync across open tabs. The three
non-light theme stylesheets load as media-gated <link>s so they never block rendering,
and font preloads can ship as HTTP Link headers so fetches start before the HTML
parses.
Hydration safety is a shipped primitive, not an app-side hack.
BrowserOnly takes children as a render function so
client-only code can't even evaluate during SSR. Every browser-API hook declares its
server answer: useBreakpoint returns "default",
useMatchesMediaQuery returns false, and useIsHydrated, built on
useSyncExternalStore precisely for consistent server and client snapshots, is the
sanctioned escape hatch.
Tailwind is the design language
There is no theme object and no CSS-in-JS: the design system is a Tailwind CSS 4
CSS-first configuration. Tokens layer from raw oklch palettes through semantic aliases to
surface and intent tokens, so one authored class (bg-card, text-muted) is correct in
both resolved themes: light and dark.
dark: is an override, not a requirement, and even white and black are semantic,
theme-swapped tokens. Because the tokens are expressed as Tailwind utilities, you extend
components with the same vocabulary you already know, so custom styling stays on-system
by default. Colors and Typography document the
vocabulary.
Your overrides always win. Every component composes consumer className last
through cx, whose merge engine Mantle vendors with the house spacing scale
baked in rather than taking clsx and tailwind-merge as dependencies: className merging is
core infrastructure, so Mantle owns it. Interactive state is styled through custom
Tailwind variants that match the DOM the headless layers
actually emit (data-state-*, aria-*), and key internal defaults are wrapped in
:where() so a single utility class from you outranks them.
Performance happens before the browser
The fastest code is the code that never runs, and the most reliable code is the code that doesn't exist. CodeBlock highlights with Shiki at build time, so no highlighter JavaScript ships to the browser. Its React-free core is published as highlight-utils so servers, workers, and build tools can produce the same HTML. The Tailwind source plugin scopes the utility scan to the Mantle components an app actually imports, so unused components cost no CSS, and non-active theme stylesheets never block rendering.
The runtime work that remains gets moved to the platform or measured down.
Tabs' edge fade and the scroll fade utilities
are CSS scroll-driven animations with zero scroll listeners.
Table coalesces resize, mutation, and scroll into one
requestAnimationFrame layout read to drive its overflow affordance. And
useBreakpoint shares a single app-wide set of matchMedia subscriptions
across every caller. It caches its subscribe functions because unstable references
measurably made window resizing sluggish.
Safe, ecosystem-normal defaults
A default is the library exercising judgment on your behalf, so Mantle's are safe and
unsurprising. Button defaults type="button", matching the
ecosystem and ending accidental form submits. AlertDialog's
Action deliberately doesn't close the dialog, so destructive handlers must opt in to
dismissal. Anchor's rel accepts an array it merges and de-dupes,
so noopener and noreferrer compose safely. QrCode renders
static black-on-white in every theme because inverted codes fail real scanners. And
wrapped event handlers run your callback first and bail if you called
event.preventDefault().
Docs are executable contracts
Every export carries JSDoc with @example blocks and when-to-use guidance, and those
examples feed the published types, llms.txt, and the component manifest. That makes
them normative: humans and AI agents reproduce them verbatim (one recorded migration
copied the patterns the docs showed into ~57 call sites before the docs were fixed), so
we maintain examples with the same care as API. Every docs page ships a
plain-markdown twin, and agents get a canonical entry point at
For AI Agents, because this library now has two audiences that write
code with it.
Decisions, recorded
These principles are conclusions, not aspirations. Significant trade-offs land as
decision records in the repository's
decisions/ directory, each with
alternatives considered and honest negative consequences: the compound-namespace record
admits the tree-shaking cost, and the button-type record names the silent non-submit
footgun it accepts. Their conclusions are distilled into
conventions that gate new
work, so the next component starts where the last argument ended. Unstable surface says
so out loud: Calendar ships flagged @preview with an
open invitation to file issues. Breaking cleanups wait for majors. A principle you
can't cite is a slogan; every principle on this page has a paper trail.