Skip to content

Utility CSS and Tailwind

View as Markdown

Utility 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.

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.

This is the whole thing: no theme CSS, one core stylesheet per component, and every visual decision in a utility class.

app.css
@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. popovertarget and popover do that before any script runs.
  • Core CSS gives the host display: contents, makes the panel scroll, and anchors it under the trigger with position-area.
  • Registration adds what neither of those does: aria-controls, aria-expanded, aria-haspopup, and role="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.

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>

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>

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.

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.