Skip to content

an HTML-first
design system.

Native HTML. Modern CSS. Built-in accessibility. Progressive custom elements by default, Shadow DOM only when it pays off. A foundation you rename and ship as your own — starting with the l- prefix.

v0.14.0// public preview

What is Luxen UI?

Luxen UI is an HTML & CSS-first foundation for design systems — rename the l- prefix to ship it under your own name. It styles native HTML elements and extends HTML with Progressive Custom Elements (<l-*>) — light DOM by default, Shadow DOM only where it pays off. Accessibility is built in throughout.

Luxen elements come in four flavors, from zero-JavaScript native elements to fully encapsulated custom elements:

Native element

A standard HTML element styled purely with CSS — no JavaScript at all.

<button class="l-button">

Progressive Custom Element

Wraps and enhances the HTML composed inside it; all its HTML and CSS live in the light DOM.

<l-input-stepper>

Plain Custom Element

A brand-new tag with no native equivalent — CSS lives in the light DOM, no Shadow-DOM.

<l-badge>

Shadow-DOM Custom Element

Encapsulated rendering — best suited to UI patterns where server-side rendering is not required.

<l-tooltip>

See each one in action — same HTML / CSS / JS pattern throughout:

Native element
html
<!-- A native element + a class. That's the whole component. -->
<button
  class="l-button"
  data-variant="primary"
>
  Save changes
</button>
css
@import 'luxen-ui/css/preset'; /* tokens + base, once */
@import 'luxen-ui/css/button';
js
// None — native elements need no JavaScript.
Progressive Custom Element
html
<!-- Wraps a native <input> — usable as a plain field before JS upgrades it -->
<l-input-stepper>
  <input
    type="number"
    value="3"
    min="0"
    max="9"
  />
</l-input-stepper>
css
@import 'luxen-ui/css/preset';
@import 'luxen-ui/css/input-stepper/default';
js
import 'luxen-ui/input-stepper';
Plain Custom Element
html
<!-- A new tag with no native equivalent, living in the light DOM -->
<l-badge variant="success">Active</l-badge>
css
@import 'luxen-ui/css/preset';
@import 'luxen-ui/css/badge';
js
import 'luxen-ui/badge';
Shadow-DOM Custom Element
html
<!-- Encapsulated overlay; bring your own trigger -->
<button
  id="save"
  class="l-button"
>
  Save
</button>
<l-tooltip for="save">Saves your changes</l-tooltip>
css
@import 'luxen-ui/css/preset'; /* the tooltip's styles ship inside its JS */
js
import 'luxen-ui/tooltip';

Every HTML element — the highlighted ones are styled or extended by Luxen, each tinted by its flavor (Native Progressive Plain Shadow-DOM).

<a><abbr><address><area><article><aside><audio><b><base><bdi><bdo><blockquote><body><br><button><canvas><caption><cite><code><col><colgroup><data><datalist><dd><del><details><dfn><dialog><div><dl><dt><em><embed><fieldset><figcaption><figure><footer><form><h1><h2><h3><h4><h5><h6><head><header><hgroup><hr><html><i><iframe><img><input><ins><kbd><label><legend><li><link><main><map><mark><math><menu><meta><meter><nav><noscript><object><ol><optgroup><option><output><p><picture><portal><pre><progress><q><rp><rt><ruby><s><samp><script><search><section><select><slot><small><source><span><strong><style><sub><summary><sup><svg><table><tbody><td><template><textarea><tfoot><th><thead><time><title><tr><track><u><ul><var><video><wbr><l-alert><l-alert-dialog><l-avatar><l-badge><l-button-group><l-carousel><l-carousel-item><l-combobox><l-dialog><l-divider><l-icon><l-dropdown><l-dropdown-item><l-dropdown-label><l-form-field><l-input-group><l-input-otp><l-input-stepper><l-popover><l-prose-editor><l-rating><l-skeleton><l-slider><l-spinner><l-sticky-bar><l-stories><l-story><l-stories-viewer><l-tabs><l-toast><l-tooltip><l-tree><l-tree-item>

A hybrid Light / Shadow DOM approach

Most web component libraries render every component into its own shadow root — even when a native element already exists.

Luxen treats Shadow DOM as a tool, not an architecture. It styles native elements, ships light-DOM custom elements, and reserves Shadow DOM for the few components that genuinely need encapsulation. Most of the library is plain HTML your server renders and your CSS can reach — nothing to hydrate, no shadow boundary in the way.

100% Shadow DOM library

every component encapsulated · SSR retrofitted

<x-button>Save</x-button>
<!-- an empty box until JS runs -->
  1. Serializerender Declarative Shadow DOM for every component, via a per-framework plugin
  2. Loada dedicated SSR loader must import hydration support before any component
  3. Hydratewait for whenDefined() + first update before setting any property
  4. Interactiveslots can't be detected server-side; icons stay blank until upgrade
  • framework plugin required
  • hydration order matters
  • “not meant to fully work without JavaScript”

Luxen UI

HTML-first · Shadow DOM when it pays off

<button class="l-button">Save</button>
<!-- a real button, already works -->
  1. Renderthe server sends plain HTML — the markup you write is the markup you ship
  2. PaintCSS styles it: themed, accessible and functional before any JavaScript
  3. UpgradeJS enhances behavior where it pays off — keyboard nav, positioning, animation
  • zero hydration
  • nothing to serialize
  • usable without JS
The platform caught up

Each of these used to justify a JavaScript component. Today it's HTML and CSS:

<dialog>popoverinvokers (command/commandfor)::picker(select)::details-content@scope@starting-stylelight-dark()anchor positioningElementInternals

So Luxen styles native elements first (.l-button on <button>, .l-select on <select>, .l-disclosure on <details>), wraps natives to add behavior (<l-input-stepper> around <input type="number">), and ships new tags only where HTML has no answer.

html
<!-- A native accordion — the one powering this very FAQ. No JS component. -->
<details
  class="l-disclosure"
  data-marker="arrow"
>
  <summary>Toggle</summary>
  <div>Revealed on open, animated in pure CSS.</div>
</details>
Composition over encapsulation

HTML is composition, and Luxen keeps it that way — <l-form-field> wraps your <input>, <l-tabs> upgrades your buttons and panels, <l-button-group> groups real <button>s. The tree you compose is the tree that renders, instead of being projected into hidden markup behind a shadow boundary.

html
<l-form-field>
  <label for="email">Email</label>
  <input
    id="email"
    type="email"
    name="email"
  />
</l-form-field>
html
<l-button-group label="Alignment">
  <button class="l-button">Left</button>
  <button class="l-button">Center</button>
  <button class="l-button">Right</button>
</l-button-group>
html
<l-tabs variant="line">
  <div>
    <button name="account">Account</button>
    <button name="password">Password</button>
  </div>
  <div>Make changes to your account here.</div>
  <div>Change your password here.</div>
</l-tabs>
html
<l-input-stepper>
  <input
    type="number"
    min="0"
    max="10"
    value="5"
  />
</l-input-stepper>
Server rendering, no hydration

The ecosystem moved back to the server: Astro, Nuxt, React Server Components, HTMX. A 100% Shadow DOM library has to retrofit SSR — Declarative Shadow DOM serialization, per-framework plugins, dedicated hydration loaders, strict ordering rules — and even then hydrates late, shifting layout and flashing invisible content on the way.

Luxen dissolves the problem instead. For native, progressive and light-DOM elements — most of the library — the HTML your server sends is the final page: it renders inside React Server Components, holds its layout before any script runs, and leaves nothing to serialize, hydrate, or flash.

html
<!-- The exact bytes your server sends — styled and final, zero hydration -->
<button
  class="l-button"
  data-variant="primary"
>
  Save
</button>
<progress
  class="l-progress"
  value="0.7"
  aria-label="Saving…"
></progress>
Third-party tools see the real DOM

Analytics, A/B testing tools and browser extensions hook into the page with CSS selectors and DOM queries that stop at a shadow boundary — a closed shadow root is opaque to them, and even open ones need deliberate traversal most tools skip. Luxen renders into the light DOM, so the tooling around your app targets the same nodes the user interacts with: trackers fire where they expect, and A/B editors restyle real elements instead of an unreachable shell.

html
<!-- A real light-DOM node — your analytics selector matches it directly -->
<button
  class="l-button"
  data-analytics="signup"
>
  Sign up
</button>
js
// No shadow root to pierce — the element is right there in the document
document.querySelector('[data-analytics="signup"]');
Forms stay native

Browser autofill, password managers, constraint validation and screen readers work unmediated — no ElementInternals emulation layer between the user and the control. Cross-shadow-root ARIA references (a label pointing at an input inside another root) remain an unsolved platform problem; Luxen sidesteps it entirely: <l-form-field> wires for, id and aria-describedby between real nodes in the same DOM tree. ElementInternals appears only where no native control exists, like <l-rating>.

html
<form>
  <l-form-field>
    <label for="email">Email</label>
    <!-- A real <input>: autofill, :invalid, required and submit all just work -->
    <input
      id="email"
      type="email"
      name="email"
      required
    />
  </l-form-field>
  <button
    class="l-button"
    type="submit"
  >
    Subscribe
  </button>
</form>
Styled with plain CSS

Light DOM means the DOM you see in DevTools is the real DOM. Style Luxen elements with plain CSS, your own design tokens, or Tailwind utilities — no waiting for the library to expose the right ::part(). Customization is layered: design tokens (--l-*) → per-element CSS custom properties → ordinary selectors, in that order of preference.

css
/* Ordinary selectors — no ::part(), no shadow boundary to cross */
.l-button {
  --size: 2.75rem; /* public per-element custom property */
}

l-badge[variant='success'] {
  background: var(--l-color-green-100);
}
Where Shadow DOM earns its place

This is not a crusade against Shadow DOM. Tooltips, dialogs, dropdowns and trees have internal structure that nothing should index and no server needs to render — a closed tooltip has no SEO value. There, encapsulation is the right tool, and Luxen uses it, with :not(:defined) rules so overlays never flash before they upgrade.

The honest converse: if you ship widgets into pages you don't control — third-party embeds, hostile global CSS — a fully encapsulated library is the better fit. Luxen is built for products where you own your page.

html
<!-- Shadow DOM where it earns it: an encapsulated overlay, no SSR needed -->
<button
  id="save"
  class="l-button"
>
  Save
</button>
<l-tooltip for="save">Saves your changes</l-tooltip>

White-label and extend

A design system has a name — and it isn't "Luxen". Luxen is the base you start from, not the brand you ship under. One config file renames every l- identifier it generates — tags, CSS classes, custom properties, keyframes, runtime IDs — to yours, rewritten at build time with zero runtime cost.

Luxen default
  • <l-badge>
  • .l-button
  • --l-color-text
  • @keyframes l-fade
Your design system
  • <acme-badge>
  • .acme-button
  • --acme-color-text
  • <acme-map>your own
js
import { defineConfig } from 'luxen-ui';

export default defineConfig({
  elementPrefix: 'acme', //  <l-badge>  →  <acme-badge>
  cssPrefix: 'acme', //  .l-button  →  .acme-button
});
One namespace for Luxen and your own components

Your own custom elements live under the same prefix. <acme-badge> (from Luxen) and <acme-map> (yours) read as a single, coherent system — no l-* seams showing through someone else's library. The generated type map is yours to edit: drop elements you don't use, add your own.

Start on Luxen, eject when you outgrow it

Luxen is a starting point, not a lock-in. When an element no longer fits your needs, stop using it and ship your own custom element in its place — same prefix, same design tokens, same conventions. The swap is invisible to the rest of your code; consistency survives it.

Extensible and customizable by design

Theming is layered and standards-based: design tokens (--l-*) for the global system, per-element CSS custom properties for local overrides, data-* attributes for variants, and ::part() where a component uses Shadow DOM.

Full setup, TypeScript types, and Vue/Nuxt strict-template support: Customizing the prefix.