Primitives and Themes
lynx-ui is an unstyled component library (also known as headless). Inspired by Radix Primitives and Base UI, it splits each component into two layers:
- Primitives are the top-level components you import, such as
Switch,Popover, orSheet. Each primitive handles one interaction pattern end to end, but makes no decisions about appearance. - Parts are the structural pieces that make up a primitive. A
Switchis composed ofSwitchTrackandSwitchThumb. Each part is a separate element you render and style independently.
Since primitives ship with no visual design, you control appearance through two mechanisms:
- Styling defines the appearance of a component instance: its size, spacing, transitions, and state-specific visuals.
- Theming defines reusable semantic tokens that many components consume, so a single change to the token layer propagates everywhere.
This guide walks through both in practice, using LUNA as the theme layer (you can bring your own) and a single Switch example that evolves from a bare composition into a reusable themed wrapper, in three steps:
Setup
For plain CSS, import LUNA styles so token variables are available as CSS variables at runtime.
For Tailwind, use LUNA tailwind preset to further expose the same tokens
as utilities such as bg-primary and text-content. Tailwind on Lynx requires
@lynx-js/tailwind-preset for runtime compatibility.
Some Tailwind integrations can provide source scanning automatically, so the
content field may not be required in every setup. The examples in this guide
use an additional integration layer beyond the basic Tailwind setup.
Styling the Parts
Every part of a lynx-ui component is intended to be styled by the
application. You render the part and attach your own classes. There is no
hidden built-in visual to override.
Using Switch:
The primitive supplies interaction and state. Your code supplies the visual rules: root size and layout, track and thumb appearance, and how the component responds to its various states.
The same composition works equivalently with Tailwind utilities. The only difference is where the styling rules live:
A note on platform props: when a primitive needs to expose lower-level
platform attributes, it does so through explicit props such as switchProps
rather than mixing them into the styling API. This keeps the className surface
predictable while still allowing platform-level customization where needed.
Styling States
Stateful primitives expose styling hooks for component state. This serves the
same purpose as the data-* state attributes used by libraries such as Radix
UI and Headless UI, and the data-* variants commonly used in Tailwind. In
lynx-ui, these hooks are exposed as UI Variants, implemented as UI state
classes:
ui-checkedui-activeui-disabled
This is what makes headless primitives easy to style without hardcoding design inside the component implementation.
In plain CSS, you target these states with selectors:
In Tailwind, the same states are expressed with variant chains:
This guide uses transform-[translateX(...)] instead of translate-x-[...]
for transform values.
@lynx-js/tailwind-preset is currently built on top of Tailwind v3, but it
adds support for this transform syntax. translate-x-[...] relies on CSS
variables, which have known issues in lower-version Lynx runtimes when used
with transitions or animations.
When multiple states can be active at the same time, such as checked and pressed, make priority explicit. The ui-checked:ui-active: chain above is
intentional: it overrides the plain ui-checked translation when both states
apply, so the visual stays coherent during interaction. Whether you use
selectors or utilities, treat overlapping states as a design decision rather
than something to leave to specificity.
Consuming Tokens
Notice that the styles above never hard-code literal colors. Instead, they
reference semantic tokens such as primary and content.
Semantic tokens name the intent of a value in the interface, not the raw value
itself. For example, primary means “the main accent in the current theme”,
and content means “primary foreground content”.
In this guide, you will most often see those tokens consumed as CSS property
values (var(--primary) and var(--content)) or Tailwind utilities
(bg-primary and text-content). These forms all resolve to the same runtime
token values.
Semantic tokens matter for two reasons:
- Consistency. A
Button, aSwitch, and aCheckboxthat all read fromprimarystay in sync automatically. Changing the token updates every surface that consumes it. - Themeability. Components depend on roles such as
primaryandcontent, rather than concrete colors. A theme can change what those roles resolve to across light, dark, or brand variants without rewriting component styles.
In practice, this means the concrete values for these semantic roles are provided by the active theme scope on an ancestor node, following the same ancestor-based theme switching pattern described in the custom theming guide, where a theme class is applied at the container level and descendant styles resolve against that shared theme scope.
Instead of branching styles per theme:
Prefer semantic tokens over color scales
In Tailwind utilities, the prefix describes the CSS property, such as bg- for
background color or text- for text color. The value after the prefix is what
matters for theming.
Values such as pink-500 and pink-300 may still come from a design scale,
but that scale is usually global: pink-500 means the same concrete color
regardless of whether the current theme is light, dark, or brand-specific.
This makes it less suitable for components that need to adapt across multiple
themes.
Semantic values such as primary and primary-content describe the role of a
style instead. The active theme decides what those roles resolve to, so the
same component style can stay stable while the visual result changes with the
theme.
Components should consume semantic tokens and stay visually stable:
Theme switching then happens at the container level:
Changing the ancestor theme class, for example from lunaris-dark to
lunaris-light, updates the token values for the entire subtree without
rewriting individual component styles.
In lynx-ui, this container-scoped theme model is implemented by LUNA, which
provides four built-in theme classes: luna-light, luna-dark,
lunaris-light, and lunaris-dark.
For token names, surface hierarchy, and theme overriding, see the LUNA Themes and Tokens documentation.
Composing Themed Wrappers
Once a styling pattern repeats, the next step is to compose a themed wrapper on top of the primitive. This gives you a stable, product-facing API without losing the flexibility of the underlying parts.
The pattern is to wrap each part separately, encode your defaults in those wrappers, and export a convenience composition for the common case:
A few things to notice in this final form:
- Each part is wrapped individually, so consumers can still override any one of them without rebuilding the rest.
- The convenience
ThemedSwitchcovers the common case but does not block the part-level API. - Defaults for sizing, transitions, and state visuals are encoded once and reused everywhere.
- The token layer (
bg-primary,bg-neutral-faint,bg-primary-content) carries through unchanged; the wrapper adds defaults, not new colors.
This is a typical shadcn/ui-style composition pattern: primitives stay public and composable, while themed components become the default product-facing implementation.
The example below applies the pattern in a real UI: a controlled switch drives container theme,
and nested surfaces can override theme independently. The same demo is embedded on the <Switch> page.
Best Practices
- Keep primitives visually unopinionated. The application owns appearance.
- Style each rendered part explicitly rather than relying on hidden defaults.
- Prefer semantic tokens over literal colors, even early in development.
- Make state priority explicit when multiple states can overlap.
- Promote a styled pattern to a wrapper only after it repeats, not before.
- Treat theming as a shared vocabulary across surfaces, not as a color dump.
Summary
lynx-ui primitives are unstyled: they give you full control over appearance without taking behavior away from you. Style the parts directly with plain CSS or Tailwind, consume semantic tokens from a layer like LUNA so visual decisions stay consistent, and compose themed wrappers when a pattern is ready to be reused.