Customization
Theming
Every colour and corner in HTMX UI comes from a CSS variable. Built-in themes restyle the library in one import; override the variables to go further, and scope them to restyle part of a page.
How it works#
Theming happens in two layers, both in htmx-ui/styles.css:
- Variables. Plain custom properties on
:roothold the actual values, with a second set under:root.dark. - Tailwind colours. A
@theme inlineblock maps each variable to a Tailwind colour, so--primarybecomesbg-primary,text-primary,border-primary/30and so on.
:root {
--radius: 0.5rem;
--primary: var(--color-indigo-600);
--primary-foreground: var(--color-white);
/* … */
}
:root.dark {
--primary: var(--color-indigo-500);
/* … */
}
@theme inline {
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
/* … */
}
Because the mapping is inline, the generated utilities read the variable at runtime:
.bg-primary compiles to background-color: var(--primary). Change the variable
anywhere (globally, under .dark, or on one element) and everything using it updates. No rebuild needed.
Tokens#
Components only use these tokens. Swatches show the current theme; toggle dark mode in the header to compare.
| Variable | Utility | Used for | Light | Dark |
|---|---|---|---|---|
--background
|
bg-background |
Page background | white | zinc-950 |
--foreground
|
text-foreground |
Default text | zinc-950 | zinc-50 |
--surface
|
bg-surface |
Cards, alerts, raised panels | white | zinc-900 |
--muted
|
bg-muted |
Subtle fills: code, table heads | zinc-100 | zinc-800 |
--muted-foreground
|
text-muted-foreground |
Secondary text | zinc-500 | zinc-400 |
--accent
|
bg-accent |
Hover and selected backgrounds | zinc-100 | zinc-800 |
--accent-foreground
|
text-accent-foreground |
Text on accent | zinc-900 | zinc-50 |
--border
|
border-border |
Borders and dividers (the default border colour) | zinc-200 | zinc-800 |
--ring
|
outline-ring |
Focus rings | indigo-500 | indigo-400 |
--primary
|
bg-primary |
Brand colour: primary buttons, links | indigo-600 | indigo-500 |
--primary-foreground
|
text-primary-foreground |
Text on primary | white | white |
--secondary
|
bg-secondary |
Secondary buttons and badges | zinc-100 | zinc-800 |
--secondary-foreground
|
text-secondary-foreground |
Text on secondary | zinc-900 | zinc-50 |
--info
|
text-info |
Info alerts and badges | sky-600 | sky-400 |
--success
|
text-success |
Success alerts and badges | emerald-600 | emerald-400 |
--warning
|
text-warning |
Warning alerts and badges | amber-600 | amber-400 |
--danger
|
bg-danger |
Destructive buttons, error alerts | red-600 | red-500 |
--danger-foreground
|
text-danger-foreground |
Text on danger | white | white |
--sidebar
|
bg-sidebar |
Sidebar background | zinc-50 | zinc-900 |
--sidebar-foreground
|
text-sidebar-foreground |
Sidebar text and icons | --foreground | --foreground |
--sidebar-primary
|
bg-sidebar-primary |
Accent fills in the sidebar, such as a logo tile | --primary | --primary |
--sidebar-primary-foreground
|
text-sidebar-primary-foreground |
Text on sidebar primary | --primary-foreground | --primary-foreground |
--sidebar-accent
|
bg-sidebar-accent |
Hovered and current sidebar items | --accent | --accent |
--sidebar-accent-foreground
|
text-sidebar-accent-foreground |
Text on sidebar accent | --accent-foreground | --accent-foreground |
--sidebar-border
|
border-sidebar-border |
Sidebar borders, separators and the rail | --border | --border |
--sidebar-ring
|
ring-sidebar-ring |
Focus rings in the sidebar | --ring | --ring |
--scrollbar-thumb
|
(scrollbars) |
Scrollbar thumb; every scrollbar is thin with a transparent track | foreground at 16% | foreground at 16% |
--scrollbar-thumb-hover
|
(scrollbars) |
Scrollbar thumb while hovering the scroll area | foreground at 32% | foreground at 32% |
--radius |
rounded-(--radius) |
Corner radius for buttons, inputs, alerts; cards add 4px | 0.5rem | 0.5rem |
Values are Tailwind palette colours, for example var(--color-indigo-600). Any CSS colour works: hex, rgb(), oklch().
The --sidebar-* tokens colour the Sidebar, so it can be tinted
apart from the page. Apart from --sidebar, they follow the token named in the table unless you set
them (Sidebar theming).
Overriding tokens#
Redefine variables after importing the library. Set both the light and dark values for anything that should differ between them.
@import "tailwindcss";
@import "htmx-ui/styles.css";
:root {
--primary: oklch(0.6 0.2 30); /* coral */
--primary-foreground: white;
--radius: 0.75rem;
}
:root.dark {
--primary: oklch(0.7 0.17 30);
}
:root and :root.dark inside @layer base.
Unlayered CSS beats layered CSS, so overrides written outside any layer (as above) always win, whatever the import order.
Built-in themes#
The package ships six ready-made themes under htmx-ui/themes/:
aurora, ember, lagoon, dusk,
jade and onyx. They are opt-in and take two steps: import the
files you want, then set data-theme on <html> for the whole site
or on any element for one part of a page. Projects that don't import a theme ship none of its
CSS, and importing without the attribute styles nothing — so only what you use ships, and only
where you ask for it. Each one in detail — preview, exact palette, a button that applies it to
this site — is on the Themes page.
@import "tailwindcss";
@import "htmx-ui/styles.css";
/* One, or several — an attribute picks which applies where. */
@import "htmx-ui/themes/aurora.css";
@import "htmx-ui/themes/onyx.css";
<!-- Whole site -->
<html lang="en" data-theme="aurora">
<!-- Or one section -->
<section data-theme="dusk">…</section>
Switching themes#
The options are buttons carrying data-theme-palette — this site's header is one,
built on a dropdown so it works from the keyboard. initTheme() (already running
if you followed the install guide) wires the clicks: the chosen palette lands on
<html> as data-theme, the option marks itself checked, and the
choice is remembered in localStorage under palette.
getPalette() and setPalette(name) are exported for switching from
your own code. The theme-swatch dot paints itself with the palette in effect —
give a swatch its own data-theme to show one specific palette instead.
<div class="dropdown" data-dropdown>
<button type="button" class="btn btn-ghost btn-icon" data-dropdown-trigger aria-haspopup="menu" aria-expanded="false" aria-label="Switch color theme">
<span class="theme-swatch" aria-hidden="true"></span>
</button>
<div class="dropdown-menu w-44" role="menu" hidden>
<p class="dropdown-label">Color theme</p>
<button type="button" class="dropdown-item" role="menuitemradio" aria-checked="true" data-theme-palette="aurora">
<span class="theme-swatch" data-theme="aurora" aria-hidden="true"></span>Aurora
</button>
<button type="button" class="dropdown-item" role="menuitemradio" aria-checked="false" data-theme-palette="ember">
<span class="theme-swatch" data-theme="ember" aria-hidden="true"></span>Ember
</button>
<!-- … one per theme you imported -->
</div>
</div>
Like the light/dark switch, apply the stored choice before first paint with a tiny inline
script in the layout's <head>, reading the same keys as
htmx-ui/theme:
<script>
const palette = localStorage.getItem("palette");
if (palette) document.documentElement.dataset.theme = palette;
</script>
Both opt-in steps still hold: a picker can only switch to what the project imported, and it
writes the same data-theme attribute the markup uses.
Custom themes#
A theme is just a selector that redefines tokens — what the built-in files are. Scope one to an
attribute and you can apply it to a whole page or a single section. These previews use the same
markup, with a different data-theme on each wrapper:
default
ProSame markup, different tokens.
rose
ProSame markup, different tokens.
emerald
ProSame markup, different tokens.
<div class="grid w-full gap-4 sm:grid-cols-3">
<div class="card">
<div class="card-header">
<div class="flex items-center justify-between">
<h3 class="card-title capitalize">default</h3>
<span class="badge badge-primary">Pro</span>
</div>
<p class="card-description">Same markup, different tokens.</p>
</div>
<div class="card-footer pt-6">
<button class="btn btn-primary btn-sm">Upgrade</button>
<button class="btn btn-outline btn-sm">Later</button>
</div>
</div>
<div data-theme="rose" class="card">
<div class="card-header">
<div class="flex items-center justify-between">
<h3 class="card-title capitalize">rose</h3>
<span class="badge badge-primary">Pro</span>
</div>
<p class="card-description">Same markup, different tokens.</p>
</div>
<div class="card-footer pt-6">
<button class="btn btn-primary btn-sm">Upgrade</button>
<button class="btn btn-outline btn-sm">Later</button>
</div>
</div>
<div data-theme="emerald" class="card">
<div class="card-header">
<div class="flex items-center justify-between">
<h3 class="card-title capitalize">emerald</h3>
<span class="badge badge-primary">Pro</span>
</div>
<p class="card-description">Same markup, different tokens.</p>
</div>
<div class="card-footer pt-6">
<button class="btn btn-primary btn-sm">Upgrade</button>
<button class="btn btn-outline btn-sm">Later</button>
</div>
</div>
</div>
[data-theme="rose"] {
--primary: var(--color-rose-600);
--ring: var(--color-rose-500);
--radius: 1rem;
}
.dark [data-theme="rose"] {
--primary: var(--color-rose-500);
}
<!-- Whole page -->
<html data-theme="rose">
<!-- Or one section -->
<section data-theme="rose">…</section>
Scrollbars#
The library styles every scrollbar to match: thin, with a transparent track and a thumb tinted from
--foreground, so it blends into any theme and follows dark mode. Browsers without
scrollbar-color (Safari) get the same look through ::-webkit-scrollbar.
:root {
--scrollbar-thumb: color-mix(in oklab, var(--primary) 30%, transparent);
}
/* Native scrollbars for one element */
.native-scrollbars { scrollbar-color: auto; scrollbar-width: auto; }
Motion#
Components animate with a small set of duration and easing tokens, so the whole library moves the same way and
one override retimes it. With prefers-reduced-motion: reduce every duration is 0s:
panels still open and close, without moving.
| Token | Default | Used for |
|---|---|---|
--motion-duration-fast | 100ms | Small state changes, panels and tooltips leaving |
--motion-duration-normal | 150ms | Popovers, menus, lists, tooltips, hover cards |
--motion-duration-moderate | 200ms | Dialogs opening |
--motion-duration-slow | 300ms | Sheets |
--motion-duration-slower | 400ms | Drawers |
--motion-ease-out | quick start, soft landing | Things appearing, responding and leaving |
--motion-ease-in-out | symmetric | Things moving across the screen |
--motion-ease-drawer | long glide | Panels sliding from an edge |
--motion-ease-spring | about 8% overshoot | Playful moves; give it 200ms or more |
/* A calmer library: everything a little slower */
:root {
--motion-duration-normal: 200ms;
--motion-duration-moderate: 260ms;
}
Use them in your own markup with Tailwind's variable shorthand:
<div class="transition-opacity duration-(--motion-duration-normal) ease-(--motion-ease-out)">…</div>
Adding your own tokens#
Follow the same two steps to add a colour that Tailwind utilities can use:
:root { --brand-gradient-from: var(--color-fuchsia-500); }
:root.dark { --brand-gradient-from: var(--color-fuchsia-400); }
@theme inline {
--color-brand-from: var(--brand-gradient-from);
}
Now from-brand-from, text-brand-from and the rest are available, and they follow dark mode.
Customising components#
Component classes live in @layer components, which sits below utilities. You can restyle them two ways:
- One element: add utilities.
<button class="btn btn-primary rounded-full px-8">gets a pill shape because utilities win. - Everywhere: extend the class in your own
@layer componentsblock.
@layer components {
.btn {
@apply font-semibold tracking-tight;
}
.card {
@apply shadow-md;
}
}
Using tokens in your own markup#
Use the token utilities instead of raw palette colours, and your own UI themes and switches modes with the library:
<aside class="rounded-(--radius) border border-border bg-surface p-4 text-muted-foreground">
Follows the theme and dark mode automatically.
</aside>