---
title: Utility CSS and Tailwind
description: Style Timeless with Tailwind or any utility framework — the import
  order that decides who wins, a complete worked example, and the four conflicts
  that are possible.
editUrl: true
head: []
template: doc
sidebar:
  order: 3
  hidden: false
  attrs: {}
pagefind: true
draft: false
---

import { Aside } from '@astrojs/starlight/components'

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.

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

```css
/* 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:

```css
@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

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

```css
/* app.css */
@import '@timelessui/components/css/tokens.css';
@import '@timelessui/components/css/core/floating.css';
@import '@timelessui/components/css/core/popover.css';
@import 'tailwindcss';
```

```js
import { definePopoverElement } from '@timelessui/components/define/ui-popover'

definePopoverElement()
```

```html
<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.

## 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

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

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:

```html
<div id="panel" popover="auto" class="bg-white text-slate-900">…</div>
```

### 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

```css
[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:

```html
<div role="option" class="not-[[hidden]]:flex items-center gap-2">…</div>
```

### `: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:

```html
<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

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](/docs/styling/theming/#styling-without-the-atmosphere-theme) lists them with examples.

<Aside type="caution" title="Never target data-ui-internal-*">
  Those are private runtime hooks — the anchoring hooks among them — and they change without notice.
  Anchoring works because core owns those selectors; a utility aimed at one is a rule that will
  break.
</Aside>

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](/docs/styling/css/#bringing-your-own-theme) covers both.