A collection of Tailwind CSS v4 utilities: enter/exit animations, semantic state variants, OKLCH color palettes, and easing tokens — all authored with native v4 directives (@utility, @custom-variant, @theme), so they compose exactly like built-in classes.
Requires Tailwind CSS v4. tailugin uses the v4 CSS-first architecture. It does not work with v3.
bun add tailugin
# or
npm install tailuginImport Tailwind, then tailugin, in your entry CSS:
@import "tailwindcss";
@import "tailugin";That's the whole setup. Everything below is now available as utility classes and variants.
Prefer to pull in only what you need? Every module has its own entry point — see Granular imports.
tailugin is organized into four independent modules:
| Module | What it adds | Import |
|---|---|---|
| theme | 13 color palettes + 22 easing tokens | tailugin/theme |
| variants | 40 semantic state variants | tailugin/variants |
| utilities | Animation, sizing and alignment | tailugin/utilities |
| preflight | Typographic refinements | tailugin/preflight |
13 OKLCH palettes, each with a 12-step ramp (50–975). They add to Tailwind's default colors — nothing native is overwritten, so bg-blue-500 and bg-cactus-500 coexist.
<div class="bg-cactus-500 text-clay-50 border-dusk-200">…</div>Available palettes: cactus, clay, dusk, fig, heather, kraft, lagoon, manila, moss, orchid, silt, sumac, woad.
Every palette is built the same way — one lightness ramp, one chroma curve, and a single hue — so swapping one for another keeps contrast consistent across your UI.
sumac (red) and woad (indigo) fill the two widest gaps on the hue wheel. They are named after the dye plants rather than the colors so they never shadow Tailwind's own red and indigo, which stay available alongside them.
Because the lightness ramp is shared, every palette crosses the WCAG thresholds at the same step. Pick the step by its job, not by the color:
| On a light surface | Passes | Contrast | Use for |
|---|---|---|---|
50–200 |
— | 1.1–1.5 | Page and card backgrounds, subtle fills |
300–400 |
— | 1.9–2.8 | Dividers, disabled text, decorative only |
500 |
≥ 3:1 | 3.5–3.9 | Borders, icons, focus rings, large headings |
600 |
≥ 4.5:1 | 5.0–5.5 | Body text — the lowest step that passes AA |
700–975 |
≥ 7:1 | 7.0–19.5 | Body text and headings, AAA |
On silt-950 (dark) |
Passes | Contrast | Use for |
|---|---|---|---|
50–400 |
≥ 4.5:1 | 6.3–15.9 | Body text and headings |
500 |
~4.5:1 | 4.48–4.97 | Large text and headings (see note) |
600 |
≥ 3:1 | 3.2–3.5 | Borders, icons, focus rings |
700–900 |
— | 1.4–2.5 | Raised surfaces, dividers, decorative only |
975 |
— | 1.3 | The surface below the page background |
On a dark surface, step
500sits right on the AA line. It clears 4.5:1 for eleven palettes but lands at 4.48 and 4.49 forsumacandclay, the two most saturated ones. Use400when the text is small.
Every swatch fits inside sRGB, so nothing shifts on displays without a P3 gamut.
950 is a charcoal rather than a near-black, which is deliberate — the ramp is drawn for light interfaces first. 975 is the extra anchor below it, six lightness points down, the same gap that separates 800 from 900. That is enough separation to read as elevation without a shadow:
| Layer | Step |
|---|---|
| Page background | 975 |
| Raised surface | 950 |
| Border | 900 / 800 |
Enter/exit animations built on two base utilities — animate-in and animate-out — combined with modifier utilities that describe the motion.
<!-- A popover that fades, zooms, and slides in from the top -->
<div class="animate-in fade-in zoom-in slide-in-top-2 duration-200">…</div>animate-in / animate-out are the triggers — they start the animation. The modifiers (fade-in, zoom-in, slide-in-top-*, …) only describe how the element moves; on their own they do nothing until a trigger runs. This means you can put the modifiers on the element unconditionally and only toggle the trigger:
<div
class="fade-in slide-in-top-2 fade-out slide-out-top-2
is-open:animate-in is-closed:animate-out duration-200"
>
…
</div>The state prefix appears twice (on the triggers), not on every modifier.
Duration and easing use Tailwind's native utilities — no special classes needed:
<div class="animate-in fade-in duration-300 ease-out-expo">…</div>duration-*— sets the animation duration (defaults to150ms).ease-*— sets the timing function (see Easings).delay-*— sets the animation delay.
Each modifier works without a value (using a sensible default) or with one for full control.
| Utility | Without value | With value |
|---|---|---|
fade-in / fade-out |
opacity from 0 |
fade-in-90 (from 90%), fade-in-[0.7] |
zoom-in / zoom-out |
scale from 0.95 |
zoom-in-50, zoom-in-[0.8] |
blur-in / blur-out |
blur 4px |
blur-in-8, blur-in-[2px] |
spin-in / spin-out |
requires a value | spin-in-90, -spin-in-45, spin-in-[30deg] |
slide-in-{top,bottom,left,right} |
requires a value | slide-in-top-8, slide-in-left-[3rem] |
slide-out-{top,bottom,left,right} |
requires a value | slide-out-top-8 |
Naming:
inmeans where it comes from,outmeans where it goes to.slide-in-topenters from above;slide-out-topexits upward.
| Utility | Sets |
|---|---|
fill-mode-{none,forwards,backwards,both} |
animation-fill-mode |
direction-{normal,reverse,alternate,alternate-reverse} |
animation-direction |
repeat-{n,infinite} |
animation-iteration-count |
play-state-{running,paused} |
animation-play-state |
The same modifiers can be driven by scroll position instead of by time. Add a timeline and the animation plays as the element moves through the scrollport:
<div class="animate-in fade-in slide-in-bottom-4 timeline-view timeline-entry">…</div>| Utility | Sets |
|---|---|
timeline-view |
Driven by the element crossing the scrollport |
timeline-scroll |
Driven by the nearest scroll container |
timeline-scroll-root |
Driven by the document's scroll progress |
timeline-scroll-self |
Driven by the element's own scroll progress |
timeline-none |
Detaches, back to a time-based animation |
timeline-{cover,contain,entry,exit,entry-crossing,exit-crossing} |
animation-range |
A progress-based timeline needs animation-duration: auto, which the timeline-* utilities supply for you. An explicit duration-* still wins, so you can mix the two freely.
For a compound range, reach for the arbitrary property:
<div class="animate-in fade-in timeline-view [animation-range:entry_0%_cover_50%]">…</div>Scroll-driven animation is not supported everywhere. Where it is missing,
animation-timelineis ignored and the animation simply plays once on load — the same entrance you would get without a timeline.
animate-in and animate-out respect prefers-reduced-motion: reduce automatically — animation is disabled for users who ask for less motion, scroll-driven included.
CSS cannot interpolate between a length and a keyword, so height: 0 to height: auto snaps instead of animating. interpolate-allow-keywords opts a subtree into resolving the keyword and animating towards it — no wrapper element, no measuring in JavaScript, no max-height guess:
<html class="interpolate-allow-keywords">
<div class="h-0 is-expanded:h-auto transition-[height] duration-200">…</div>
</html>It covers every intrinsic keyword, not only auto — min-content, max-content and fit-content interpolate too, so a dropdown can animate to the width of its contents.
| Utility | Sets |
|---|---|
interpolate-allow-keywords |
Keywords interpolate |
interpolate-numeric-only |
The CSS default — keywords snap |
The property is inherited, which is why both values ship: set allow-keywords once on the root, then use numeric-only to opt a subtree back out where a snap is what you want.
Support is not universal. Where the property is missing the declaration is ignored and sizes snap — exactly the behavior you have without it.
align-items and justify-content are written together far more often than apart, and CSS has no shorthand for the pair — place-items sets justify-items, which does nothing in a flex container. These utilities collapse the two into one class, named {align}-{justify}:
<div class="flex center">…</div>
<div class="flex center-between">…</div>
<div class="flex baseline-evenly">…</div>| Axis | Values |
|---|---|
align |
center, start, end, stretch, baseline |
justify |
center, start, end, between, around, evenly |
Where both axes take the same keyword there is only the short form: center, start and end set both at once. They are the only three keywords valid on both axes, so there is no center-center to write.
These set alignment only. Add
flexorinline-flexyourself, so the same classes stay usable on a container you have already made flex elsewhere.
Semantic state variants that match native, data-*, and ARIA attributes at once — so they work across headless UI libraries (Radix, Ark, React Aria, Base UI) without configuration.
<div data-state="open" class="is-open:opacity-100 is-closed:opacity-0">…</div>
<li aria-selected="true" class="is-selected:bg-fig-100">…</li>Variants that describe an element's state are prefixed with is-, so they never collide with — and instead extend — Tailwind's native open:, checked:, disabled:, starting: and friends. Variants that describe where or how something sits (placement, orientation, navigation role, calendar position) use plain names.
| Group | Variants |
|---|---|
| Interaction | is-disabled, is-dragging, is-drop-target |
| Disclosure | is-open, is-closed, is-expanded, is-collapsed |
| Transition | is-starting, is-ending |
| Selection | is-highlighted, is-selected, is-checked, is-unchecked, is-indeterminate, is-pressed, is-on, is-off |
| Form | is-invalid, is-valid, is-required, is-readonly |
| Calendar | is-today, is-unavailable, range-start, range-end, outside-month |
| Placement | side-top, side-right, side-bottom, side-left, side-inline-start, side-inline-end, align-start, align-center, align-end |
| Navigation | current-page, current-step |
| Orientation | horizontal, vertical |
| Theme | dark |
Use the native
open:/disabled:/checked:for plain HTML. Theis-versions add thedata-*and ARIA state that headless components emit on non-native elements.
These are two different states, kept apart on purpose:
is-open/is-closed— an overlay or panel that mounts and unmounts: dialog, popover, menu, tooltip, accordion panel.is-expanded/is-collapsed— a trigger, row, or tree node that reportsaria-expanded: disclosure button, combobox input, expandable table row.
<!-- the trigger rotates, the panel animates -->
<button aria-expanded="true" class="is-expanded:[&_svg]:rotate-180">…</button>
<div data-state="open" class="is-open:animate-in is-closed:animate-out">…</div>is-starting / is-ending match the mount and unmount phases that Base UI (data-starting-style / data-ending-style) and React Aria (data-entering / data-exiting) expose — so they pair directly with the animation utilities:
<div class="is-starting:animate-in is-starting:fade-in is-ending:animate-out is-ending:fade-out duration-200">…</div>Floating elements are positioned on two axes, and every library exposes them as two separate values — a side and an alignment. There is no single top-left state; you compose the two variants instead:
<!-- anchored above the trigger, aligned to its start edge -->
<div class="side-top:align-start:origin-bottom-left">…</div>
<!-- enter animation follows the side the popover landed on -->
<div class="animate-in fade-in side-top:slide-in-bottom-2 side-bottom:slide-in-top-2">…</div>| Axis | Variants | Source |
|---|---|---|
| Side | side-top, side-right, side-bottom, side-left |
data-side (Radix, Ark, Base UI), data-placement (Ark, React Aria) |
| Side | side-inline-start, side-inline-end |
data-side (Base UI only — logical, resolves against direction) |
| Alignment | align-start, align-center, align-end |
data-align (Radix, Base UI, Ark) |
Alignment coverage is uneven upstream: Ark's popper components encode it inside
data-placement(top-start), whichalign-start/align-endalso match — but Ark never reports a centered alignment, and React Aria'sdata-placementcarries no alignment at all.
is-readonlydeliberately omits the native:read-onlypseudo-class, which matches every non-editable element (including plaindivs). Useread-only:for native inputs.is-invalid/is-validuse:user-invalid/:user-validrather than:invalid/:valid, so they only fire after the user has interacted with the field.is-checkedincludes[data-selected]because React Aria reports checkboxes, radios, and switches as selected rather than checked. On React Aria this makesis-checked:andis-selected:equivalent.is-on/is-offare toggle-button states (data-state="on"). A switch reports itself as checked, so useis-checked:there.
22 easing tokens exposed via @theme, so they become ease-* utilities automatically and work with any transition or animation:
<button class="transition ease-out-expo">…</button>
<div class="animate-in fade-in ease-anticipate">…</div>Available: anticipate, quick-out, in-out, in-out-base, and the in/out/in-out sets for quad, cubic, quart, quint, expo, and circ (e.g. ease-in-quad, ease-out-expo, ease-in-out-circ).
Note: tailugin redefines the native
ease-in-outtoken with a refined curve. If you rely on Tailwind's defaultease-in-out, be aware it is overridden.
Optional typographic refinements layered on top of Tailwind's own Preflight (which still handles the reset). Adds automatic hyphenation, balanced headings, pretty paragraph wrapping, tabular numerals, and code ligatures.
@import "tailwindcss";
@import "tailugin/preflight";It only adds what Preflight doesn't cover — no reset is duplicated. Unsupported properties are ignored by the browser without breaking layout.
Import only the modules — or sub-modules — you need:
@import "tailugin/theme"; /* colors + easings */
@import "tailugin/variants"; /* all variants */
@import "tailugin/utilities"; /* alignment + animation + sizing */
@import "tailugin/preflight";
/* Sub-modules */
@import "tailugin/theme/colors";
@import "tailugin/theme/easings";
@import "tailugin/utilities/alignment";
@import "tailugin/utilities/animate";
@import "tailugin/utilities/size";
/* A single variant group — calendar, disclosure, form, interaction, navigation,
orientation, placement, selection, theme, transition */
@import "tailugin/variants/placement";
/* A single color palette */
@import "tailugin/colors/fig";