lynx-ui logo
lynx-ui

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, or Sheet. 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 Switch is composed of SwitchTrack and SwitchThumb. 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:

  1. Styling the parts
  2. Consuming tokens
  3. Composing themed wrappers

Setup

For plain CSS, import LUNA styles so token variables are available as CSS variables at runtime.

@import '@lynx-js/luna-styles/index.css';

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.

// tailwind.config.ts
import { LunaPreset } from '@lynx-js/luna-tailwind';
import LynxPreset from '@lynx-js/tailwind-preset';
import type { Config } from 'tailwindcss';

const config: Config = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  presets: [LynxPreset, LunaPreset],
};

export default config;
Note

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:

import { Switch, SwitchThumb, SwitchTrack } from '@lynx-js/lynx-ui';

export function ExampleSwitch() {
  return (
    <Switch className="switch">
      <SwitchTrack className="switch-track" />
      <SwitchThumb className="switch-thumb" />
    </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:

<Switch className="flex items-center w-[48px] h-[28px] rounded-full overflow-hidden">
  <SwitchTrack className="w-full h-full" />
  <SwitchThumb className="absolute w-[22px] h-[22px] rounded-full" />
</Switch>

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-checked
  • ui-active
  • ui-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:

.switch.ui-disabled {
  opacity: 0.5;
}
.switch-track.ui-checked {
  background-color: var(--primary);
}
.switch-track.ui-active {
  background-color: var(--primary-2);
}
.switch-thumb.ui-checked {
  transform: translateX(23px);
}
.switch-thumb.ui-active {
  width: 33px;
}
.switch-thumb.ui-checked.ui-active {
  transform: translateX(12px);
}

In Tailwind, the same states are expressed with variant chains:

<SwitchTrack
  className="bg-neutral-faint
    ui-checked:bg-primary
    ui-active:bg-primary-2"
/>
<SwitchThumb
  className="bg-primary-content
    ui-checked:transform-[translateX(23px)]
    ui-active:w-[33px]
    ui-checked:ui-active:transform-[translateX(12px)]"
/>
Note

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, a Switch, and a Checkbox that all read from primary stay in sync automatically. Changing the token updates every surface that consumes it.
  • Themeability. Components depend on roles such as primary and content, 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:

<Button className="bg-pink-500 dark:bg-pink-300 text-white dark:text-black" />
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:

<Button className="bg-primary text-primary-content" />

Theme switching then happens at the container level:

<view className="lunaris-dark">
  {/* themed subtree */}
  <Button className="bg-primary text-primary-content" />
  <Switch className="switch">
    <SwitchTrack className="switch-track" />
    <SwitchThumb className="switch-thumb" />
  </Switch>
</view>

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:

import {
  Switch as SwitchPrimitive,
  SwitchThumb as SwitchThumbPrimitive,
  SwitchTrack as SwitchTrackPrimitive,
} from '@lynx-js/lynx-ui';
import type {
  SwitchProps,
  SwitchThumbProps,
  SwitchTrackProps,
} from '@lynx-js/lynx-ui';
import { clsx } from 'clsx';

export function ThemedSwitch(props: SwitchProps) {
  return (
    <ThemedSwitchRoot {...props}>
      <ThemedSwitchTrack />
      <ThemedSwitchThumb />
    </ThemedSwitchRoot>
  );
}

export function ThemedSwitchRoot({ className, ...props }: SwitchProps) {
  return (
    <SwitchPrimitive
      className={clsx(
        'flex flex-row items-center w-[48px] h-[28px] rounded-full overflow-hidden',
        'ui-disabled:opacity-50',
        className,
      )}
      {...props}
    />
  );
}

export function ThemedSwitchTrack({ className }: SwitchTrackProps) {
  return (
    <SwitchTrackPrimitive
      className={clsx(
        'size-full transition-colors duration-150 ease-out',
        'bg-neutral-faint ui-checked:bg-primary ui-active:bg-primary-2',
        className,
      )}
    />
  );
}

export function ThemedSwitchThumb({ className }: SwitchThumbProps) {
  return (
    <SwitchThumbPrimitive
      className={clsx(
        'absolute size-[22px] rounded-full bg-primary-content shadow',
        'transition-all duration-150 ease-out transform-[translateX(3px)]',
        'ui-checked:transform-[translateX(23px)]',
        'ui-active:w-[33px]',
        'ui-checked:ui-active:transform-[translateX(12px)]',
        className,
      )}
    />
  );
}

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 ThemedSwitch covers 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.

Except as otherwise noted, this work is licensed under a Creative Commons Attribution 4.0 International License, and code samples are licensed under the Apache License 2.0.