Utility CSS and Tailwind
View as MarkdownUtility CSS is a supported path, not a workaround. Timeless ships behavior and appearance in
separate tiers precisely so the appearance can be yours: import tokens.css and the core layer,
skip the theme, and every component is positioned, operable, and structurally intact with no look of
ours to fight. From there a rounded-lg or a bg-white is not overriding anything — there is
nothing to override.
One thing decides whether that works, and it fails silently when it is wrong: the import order.
Import Timeless before Tailwind
Section titled “Import Timeless before Tailwind”Tailwind v4 emits native cascade layers, and CSS orders layers by first encounter. So whichever stylesheet is parsed first gets the lower-priority layers, and layer order beats specificity outright.
/* Timeless first: `ui.*` registers first, so every Tailwind layer lands above it. */@import '@timelessui/components/css/tokens.css';@import '@timelessui/components/css/core.css';@import 'tailwindcss';That produces this layer order, which you can read in the compiled output:
@layer ui.tokens, ui.components, ui.utilities;@layer theme, base, components, utilities;Invert the two imports and ui.components lands after utilities. A utility for a property core
declares then loses, with no error and nothing in the console:
| Import order | overflow-visible on a Popover surface |
Result |
|---|---|---|
| Timeless, then Tailwind | wins | overflow: visible |
| Tailwind, then Timeless | loses to core/popover.css |
overflow: auto, no error |
Only the properties core declares can conflict at all — display, overflow, position, the
inset family, appearance, and their neighbours. Core is forbidden from declaring a colour,
radius, shadow, type, or size property, and a build check proves it, so a bg-*, rounded-*,
shadow-*, or text-* utility cannot lose to Timeless whatever the order.
Tailwind v3 emits unlayered rules, which beat every layered rule regardless of order, so v3 wins either way.
A Popover, styled entirely in Tailwind
Section titled “A Popover, styled entirely in Tailwind”This is the whole thing: no theme CSS, one core stylesheet per component, and every visual decision in a utility class.
@import '@timelessui/components/css/tokens.css';@import '@timelessui/components/css/core/floating.css';@import '@timelessui/components/css/core/popover.css';@import 'tailwindcss';import { definePopoverElement } from '@timelessui/components/define/ui-popover'
definePopoverElement()<ui-popover> <button type="button" data-ui-part="trigger" popovertarget="panel" class="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-700" > Menu </button> <div id="panel" popover="auto" aria-labelledby="panel-title" class="m-2 w-64 rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-900 shadow-lg" > <h2 id="panel-title" class="mb-1 font-semibold">Panel</h2> <p class="text-slate-600">Anchored by core CSS, styled by Tailwind utilities.</p> </div></ui-popover>What each side contributes is worth being explicit about, because it is the same division for every component:
- The platform opens and closes the surface, light-dismisses it, handles Escape, and puts it in
the top layer.
popovertargetandpopoverdo that before any script runs. - Core CSS gives the host
display: contents, makes the panel scroll, and anchors it under the trigger withposition-area. - Registration adds what neither of those does:
aria-controls,aria-expanded,aria-haspopup, androle="dialog"on the surface. - Your utilities supply every pixel of the look, including the gap between trigger and surface —
that is the
m-2, which replaces the margin the theme would have set.
Four conflicts, and what to do about them
Section titled “Four conflicts, and what to do about them”Everything above is the happy path. These are the four places where utility CSS and the platform interact in a way worth knowing in advance.
The host already has a display; do not give it one
Section titled “The host already has a display; do not give it one”An unknown element defaults to display: inline, and Tailwind’s Preflight does not reset custom
elements. Core does it for you — ui-popover is display: contents, ui-combobox is
display: grid, and so on — so there is nothing to hand-write.
The corollary follows from the import order above: because utilities win, a block or flex
utility on the host defeats display: contents and puts a box between the trigger and its
surface. Style the parts, not the host.
Style the surface’s colour, not only its background
Section titled “Style the surface’s colour, not only its background”With no theme loaded, a [popover] or <dialog> takes the UA’s color: CanvasText and
background-color: Canvas. tokens.css keeps color-scheme: light dark on purpose, so those
system colours follow the reader’s preference — which means on a dark-preferring browser
CanvasText is white. A bg-white utility with no text-* alongside it gives you white text on a
white panel, in one theme only, which is a hard bug to catch.
Set both, as the example above does:
<div id="panel" popover="auto" class="bg-white text-slate-900">…</div>Filtered options and [hidden]
Section titled “Filtered options and [hidden]”Option filtering, paging, empty groups, and the pager all express themselves by setting the native
hidden attribute, and core hides them with display: none. A display utility on an option row
is therefore a rule about the same property as the rule that hides it.
Tailwind’s Preflight already resolves this in your favour. It ships
[hidden]:where(:not([hidden='until-found'])) { display: none !important;}so with the default @import "tailwindcss" a filtered option stays hidden even under flex. If you
import Tailwind without Preflight, that rule is gone, and a flex on an option row makes every
filtered option reappear. Guard the variant instead of the property:
<div role="option" class="not-[[hidden]]:flex items-center gap-2">…</div>:state() has no built-in variant
Section titled “:state() has no built-in variant”A few components expose state through ElementInternals rather than an attribute — ui-toast has
--closed, ui-color-picker has --copied and --contextual, ui-sheet has --dragging.
Tailwind has no variant for :state(), but the arbitrary form compiles and matches:
<ui-toast class="transition-opacity [&:state(--closed)]:opacity-0">…</ui-toast>Everything else Timeless exposes is a native attribute, an ARIA attribute, or a platform
pseudo-class, all of which Tailwind already has variants for: aria-expanded:*, aria-selected:*,
disabled:*, data-[ui-variant=primary]:*, and arbitrary selectors such as
[&[data-ui-part~='trigger']]:* for authored anatomy. open:* is worth knowing on this library in
particular — in v4 it compiles to :is([open], :popover-open, :open), so one variant covers an open
<dialog> and an open popover surface alike.
What to target
Section titled “What to target”The public anatomy is the same with or without a theme, so it is what your utilities and your own
CSS attach to: .ui-* roots, data-ui-* configuration on those roots, plain attributes on ui-*
hosts, data-ui-part for authored anatomy, and native attributes plus ARIA for state.
Theming lists them with examples.
Two more things to expect on the theme-free path, neither specific to Tailwind: sizing lives in the theme, so a few components sit at their content size until you give them one, and core carries no affordances, so there is no hover highlight and no focus ring until you add them. Loading CSS covers both.