Skip to main content

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.