Native element
A standard HTML element styled purely with CSS — no JavaScript at all.
<button class="l-button">▍~/design-system/setup.config
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.
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:
A standard HTML element styled purely with CSS — no JavaScript at all.
<button class="l-button">Wraps and enhances the HTML composed inside it; all its HTML and CSS live in the light DOM.
<l-input-stepper>A brand-new tag with no native equivalent — CSS lives in the light DOM, no Shadow-DOM.
<l-badge>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:
<!-- A native element + a class. That's the whole component. -->
<button
class="l-button"
data-variant="primary"
>
Save changes
</button>@import 'luxen-ui/css/preset'; /* tokens + base, once */
@import 'luxen-ui/css/button';// None — native elements need no JavaScript.<!-- 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>@import 'luxen-ui/css/preset';
@import 'luxen-ui/css/input-stepper/default';import 'luxen-ui/input-stepper';<!-- A new tag with no native equivalent, living in the light DOM -->
<l-badge variant="success">Active</l-badge>@import 'luxen-ui/css/preset';
@import 'luxen-ui/css/badge';import 'luxen-ui/badge';<!-- Encapsulated overlay; bring your own trigger -->
<button
id="save"
class="l-button"
>
Save
</button>
<l-tooltip for="save">Saves your changes</l-tooltip>@import 'luxen-ui/css/preset'; /* the tooltip's styles ship inside its 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>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.
every component encapsulated · SSR retrofitted
<x-button>Save</x-button>
<!-- an empty box until JS runs -->whenDefined() + first update before setting any propertyHTML-first · Shadow DOM when it pays off
<button class="l-button">Save</button>
<!-- a real button, already works -->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 positioningElementInternalsSo 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.
<!-- 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>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.
<l-form-field>
<label for="email">Email</label>
<input
id="email"
type="email"
name="email"
/>
</l-form-field><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><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><l-input-stepper>
<input
type="number"
min="0"
max="10"
value="5"
/>
</l-input-stepper>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.
<!-- 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>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.
<!-- A real light-DOM node — your analytics selector matches it directly -->
<button
class="l-button"
data-analytics="signup"
>
Sign up
</button>// No shadow root to pierce — the element is right there in the document
document.querySelector('[data-analytics="signup"]');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>.
<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>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.
/* 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);
}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.
<!-- 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>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.
<l-badge>.l-button--l-color-text@keyframes l-fadeelementPrefix: 'acme'<acme-badge>.acme-button--acme-color-text<acme-map>your ownimport { defineConfig } from 'luxen-ui';
export default defineConfig({
elementPrefix: 'acme', // <l-badge> → <acme-badge>
cssPrefix: 'acme', // .l-button → .acme-button
});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.
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.
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.