

# Migrating from Tailwind

Move a Tailwind CSS v4 application to Zyzz one component at a time, with before-and-after examples for each pattern.

## Overview

Tailwind styles an element with utility classes in its markup. Zyzz names the same declarations in `style` definitions beside the component, compiles them to CSS at build time, and applies each definition by spreading the props it returns.

The examples import `style` from `zyzz/default`, which shares Tailwind's spacing, type, radius, shadow, breakpoint, and container scales. Most utilities keep their names as tokens, such as `4` for `p-4` and `'lg'` for `rounded-lg`.

Both libraries can run in one application, so migrate one component at a time. This card renders the same grid, spacing, and type before and after migrating:

```tsx title="Card.tsx"
export function Card() {
  return (
    // Utility classes set each declaration in the markup
    <article className="grid gap-4 p-6">
      <h2 className="text-xl leading-tight font-semibold">Account</h2>
    </article>
  )
}
```

```tsx title="Card.tsx"
import { style } from 'zyzz/default'

export function Card() {
  return (
    // Each element spreads the props of one named style
    <article {...styles.card()}>
      <h2 {...styles.title()}>Account</h2>
    </article>
  )
}

namespace styles {
  // grid gap-4 p-6
  export const card = style({ display: 'grid', gap: 4, padding: 6 })

  // text-xl leading-tight font-semibold
  export const title = style({
    fontSize: 'xl',
    fontWeight: 'semibold',
    lineHeight: 'tight',
  })
}
```

## Run Both

Add the Zyzz integration from [Getting Started](/docs/introduction/getting-started) beside Tailwind's. Keep `@import "tailwindcss"` and its Preflight reset until the last component migrates, so both versions of a component render on the same baseline.

While an element moves over, pass its remaining utilities to the style call as `className`:

```tsx title="SaveButton.tsx"
export function SaveButton() {
  return (
    // Every declaration comes from a utility
    <button className="rounded-lg px-4 py-2 shadow-sm">Save</button>
  )
}
```

```tsx title="SaveButton.tsx"
import { style } from 'zyzz/default'

export function SaveButton() {
  return (
    // shadow-sm stays a utility until its declarations move
    <button {...styles.button({ className: 'shadow-sm' })}>Save</button>
  )
}

namespace styles {
  // rounded-lg px-4 py-2
  export const button = style({
    borderRadius: 'lg',
    paddingBlock: 2,
    paddingInline: 4,
  })
}
```

Tailwind utilities sit in the `utilities` cascade layer, and unlayered Zyzz declarations outrank them regardless of class order. Avoid setting one property from both libraries on the same element.

## Utilities

Each utility expands to one or more declarations, so copy the full expansion. Values from Tailwind's default theme become `zyzz/default` tokens with the same names.

| Tailwind | Zyzz |
| --- | --- |
| `flex items-center` | `display: 'flex', alignItems: 'center'` |
| `grid-cols-2` | `gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'` |
| `px-4` | `paddingInline: 4` |
| `size-12` | `height: 12, width: 12` |
| `rounded-lg` | `borderRadius: 'lg'` |
| `shadow-sm` | `boxShadow: 'sm'` |
| `text-lg leading-snug` | `fontSize: 'lg', lineHeight: 'snug'` |
| `font-medium` | `fontWeight: 'medium'` |
| `truncate` | `overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'` |

Arbitrary values become plain CSS values, with spaces where Tailwind uses underscores. A property that takes tokens needs the `!custom` suffix for a value outside the scale:

```tsx title="Sidebar.tsx"
export function Sidebar() {
  return (
    // Brackets hold arbitrary values, and underscores stand for spaces
    <aside className="grid grid-cols-[12rem_1fr] w-[calc(100%-2rem)]">
      Navigation
    </aside>
  )
}
```

```tsx title="Sidebar.tsx"
import { style } from 'zyzz/default'

export function Sidebar() {
  return <aside {...styles.sidebar()}>Navigation</aside>
}

namespace styles {
  export const sidebar = style({
    display: 'grid',
    // Arbitrary values are ordinary CSS values
    gridTemplateColumns: '12rem 1fr',
    // !custom marks a width outside the spacing scale
    width: 'calc(100% - 2rem) !custom',
  })
}
```

## States

State prefixes such as `hover:` and `disabled:` become condition keys that hold the declarations to change. Tailwind's `hover:` applies only on devices that can hover, so keep that media query to match its behavior on touch screens.

```tsx title="SaveButton.tsx"
export function SaveButton(props: SaveButton.Props) {
  return (
    <button
      // Each prefix applies one utility in that state
      className="hover:opacity-80 focus-visible:outline-2 disabled:opacity-50"
      disabled={props.disabled}
    >
      Save
    </button>
  )
}

export declare namespace SaveButton {
  type Props = { disabled?: boolean | undefined }
}
```

```tsx title="SaveButton.tsx"
import { style } from 'zyzz/default'

export function SaveButton(props: SaveButton.Props) {
  return (
    <button disabled={props.disabled} {...styles.button()}>
      Save
    </button>
  )
}

export declare namespace SaveButton {
  type Props = { disabled?: boolean | undefined }
}

namespace styles {
  export const button = style({
    // hover: only matches devices that can hover
    '@media (hover: hover)': {
      ':hover': { opacity: 0.8 },
    },
    ':focus-visible': { outlineStyle: 'solid', outlineWidth: '2px' },
    ':disabled': { opacity: 0.5 },
  })
}
```

Conditions keep their authored order, so `:disabled` comes last to win over `:hover` while both match.

## Other Variants

Attribute, child, and arbitrary variants become selectors under `selectors`, with `&` for the styled element. Feature and preference variants become the at-rules they stand for.

```tsx title="Disclosure.tsx"
export function Disclosure(props: Disclosure.Props) {
  return (
    <button
      aria-expanded={props.open}
      // aria-* and [&...] variants match attributes and child elements
      className="aria-expanded:font-semibold [&>svg]:size-4"
      type="button"
    >
      Details
      <svg aria-hidden="true" viewBox="0 0 16 16" />
    </button>
  )
}

export declare namespace Disclosure {
  type Props = { open: boolean }
}
```

```tsx title="Disclosure.tsx"
import { style } from 'zyzz/default'

export function Disclosure(props: Disclosure.Props) {
  return (
    <button aria-expanded={props.open} type="button" {...styles.trigger()}>
      Details
      <svg aria-hidden="true" viewBox="0 0 16 16" />
    </button>
  )
}

export declare namespace Disclosure {
  type Props = { open: boolean }
}

namespace styles {
  export const trigger = style({
    // Each selector includes & for the button
    selectors: {
      '&[aria-expanded="true"]': { fontWeight: 'semibold' },
      '& > svg': { height: 4, width: 4 },
    },
  })
}
```

| Tailwind | Zyzz |
| --- | --- |
| `data-[state=open]:` | `'&[data-state="open"]'` |
| `*:` | `'& > *'` |
| `has-checked:` | `'&:has(:checked)'` |
| `supports-[display:grid]:` | `'@supports (display: grid)'` |
| `motion-reduce:` | `'@media (prefers-reduced-motion: reduce)'` |
| `print:` | `'@media print'` |

## Groups and Peers

`group` and `peer` mark an element that others observe. In Zyzz, an empty `style()` marks it, and dependent styles interpolate it in a selector without calling it.

```tsx title="EmailField.tsx"
export function EmailField() {
  return (
    // group marks the ancestor, and peer marks the preceding sibling
    <label className="group">
      <span className="group-hover:underline">Email</span>
      <input className="peer" required type="email" />
      <p className="invisible peer-invalid:visible">Enter a valid email.</p>
    </label>
  )
}
```

```tsx title="EmailField.tsx"
import { style } from 'zyzz/default'

export function EmailField() {
  return (
    <label {...styles.field()}>
      <span {...styles.label()}>Email</span>
      <input required type="email" {...styles.input()} />
      <p {...styles.hint()}>Enter a valid email.</p>
    </label>
  )
}

namespace styles {
  // Empty styles mark the elements that others observe
  export const field = style()

  export const input = style()

  export const label = style({
    '@media (hover: hover)': {
      selectors: { [`${field}:hover &`]: { textDecorationLine: 'underline' } },
    },
  })

  export const hint = style({
    visibility: 'hidden',
    // Matches when the input before the hint is invalid
    selectors: { [`${input}:invalid ~ &`]: { visibility: 'visible' } },
  })
}
```

A group observes an ancestor and a peer observes a preceding sibling, so keep the element order. Named groups such as `group/item` become separate marker styles.

## Breakpoints

Responsive prefixes become media queries on the breakpoint names that `zyzz/default` shares with Tailwind. Unprefixed utilities stay as base declarations, and queries keep ascending order so wider ones win.

```tsx title="Grid.tsx"
export function Grid() {
  return (
    // md: and lg: start at 48rem and 64rem by default
    <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
      <li>Account</li>
    </ul>
  )
}
```

```tsx title="Grid.tsx"
import { style } from 'zyzz/default'

export function Grid() {
  return (
    <ul {...styles.grid()}>
      <li>Account</li>
    </ul>
  )
}

namespace styles {
  export const grid = style({
    display: 'grid',
    gridTemplateColumns: 'repeat(1, minmax(0, 1fr))',
    // md and lg compile to (width >= 48rem) and (width >= 64rem)
    '@media md': {
      gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
    },
    '@media lg': {
      gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
    },
  })
}
```

`max-md:` becomes `'@media <md'`, and `md:max-lg:` becomes `'@media md..lg'`. See [Name Queries](/docs/guides/conditions#name-queries) for every alias form.

## Container Queries

The `@container` utility becomes the `containerType` property, and prefixes such as `@md:` become `@container` queries on the same container sizes.

```tsx title="Sidebar.tsx"
export function Sidebar() {
  return (
    // @container marks the container, and @md: queries its width
    <aside className="@container">
      <div className="grid grid-cols-1 @md:grid-cols-2">Content</div>
    </aside>
  )
}
```

```tsx title="Sidebar.tsx"
import { style } from 'zyzz/default'

export function Sidebar() {
  return (
    <aside {...styles.sidebar()}>
      <div {...styles.grid()}>Content</div>
    </aside>
  )
}

namespace styles {
  // Query the inline size of the sidebar
  export const sidebar = style({ containerType: 'inline-size' })

  export const grid = style({
    display: 'grid',
    gridTemplateColumns: 'repeat(1, minmax(0, 1fr))',
    // md compiles to (width >= 28rem) on the nearest container
    '@container md': {
      gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
    },
  })
}
```

A named container such as `@container/sidebar` also sets `containerName: 'sidebar'`, and its queries name it, as in `'@container sidebar (width >= 28rem)'`.

## Dark Mode

Tailwind's default `dark:` variant follows the system color scheme through a media query. Use the same query to keep that behavior while both libraries run.

```tsx title="Panel.tsx"
export function Panel() {
  return (
    // dark: follows the system color scheme by default
    <section className="bg-white text-black dark:bg-black dark:text-white">
      Account
    </section>
  )
}
```

```tsx title="Panel.tsx"
import { style } from 'zyzz/default'

export function Panel() {
  return <section {...styles.panel()}>Account</section>
}

namespace styles {
  export const panel = style({
    backgroundColor: 'white',
    color: 'black',
    // The default dark: variant uses this query
    '@media (prefers-color-scheme: dark)': {
      backgroundColor: 'black',
      color: 'white',
    },
  })
}
```

A class toggle declared with `@custom-variant dark (&:where(.dark, .dark *))` becomes the selector `'&:where(.dark, .dark *)'` instead. Geist colors in `zyzz/default`, such as `'background.primary'`, hold light and dark pairs that follow `color-scheme`. See [Themes & Tokens](/docs/guides/themes).

## Theme Tokens

`zyzz/default` holds the values of Tailwind's default theme under the same names, except for colors. Its colors come from the Geist palette, so a Tailwind color keeps its literal value.

```tsx title="Panel.tsx"
export function Panel() {
  return (
    // Each utility reads a variable from Tailwind's default theme
    <section className="rounded-lg bg-blue-600 p-4 shadow-sm md:p-6">
      Account
    </section>
  )
}
```

```tsx title="Panel.tsx"
import { style } from 'zyzz/default'

export function Panel() {
  return <section {...styles.panel()}>Account</section>
}

namespace styles {
  export const panel = style({
    // Tailwind's blue-600 is outside the Geist palette
    backgroundColor: 'oklch(54.6% 0.245 262.881) !custom',
    // Other tokens share Tailwind's names and values
    borderRadius: 'lg',
    boxShadow: 'sm',
    padding: 4,
    '@media md': { padding: 6 },
  })
}
```

| Tailwind | `zyzz/default` |
| --- | --- |
| `--spacing` | `spacing` |
| `--text-*` | `fontSize` |
| `--font-weight-*` | `fontWeight` |
| `--leading-*` | `lineHeight` |
| `--tracking-*` | `letterSpacing` |
| `--radius-*` | `radius` |
| `--shadow-*` | `shadow` |
| `--breakpoint-*` | `breakpoint` |
| `--container-*` | `container` |
| `--ease-*` | `ease` |
| `--animate-*` | `animate` |

Values that `@theme` adds or overrides, such as a brand color, need a project config. A config declares its own tokens in place of `zyzz/default`, as described in [Themes & Tokens](/docs/guides/themes).

## Conditional Classes

Tailwind calls prefixes such as `hover:` variants. Zyzz's `variants` function instead defines a component's choices, such as size or tone, with typed props and defaults. Class strings picked by a prop become its choices.

```tsx title="Button.tsx"
// A lookup table holds the class string for each size
const sizes = {
  compact: 'px-2 py-1',
  regular: 'px-4 py-2',
}

export function Button(props: Button.Props) {
  return <button className={sizes[props.size ?? 'regular']}>Save</button>
}

export declare namespace Button {
  type Props = { size?: keyof typeof sizes | undefined }
}
```

```tsx title="Button.tsx"
import type { Props } from 'zyzz'
import { variants } from 'zyzz/default'

export function Button(props: Button.Props) {
  return <button {...styles.button({ size: props.size })}>Save</button>
}

export declare namespace Button {
  type Props = Props.Variants<typeof styles.button>
}

namespace styles {
  export const button = variants({
    // The default replaces the lookup's fallback
    defaultVariants: { size: 'regular' },
    variants: {
      size: {
        compact: { paddingBlock: 1, paddingInline: 2 },
        regular: { paddingBlock: 2, paddingInline: 4 },
      },
    },
  })
}
```

`Props.Variants` infers the component's styling props from the definition. See [Variants](/docs/guides/variants) for compound rules and conditional choices.

## Dynamic Values

Tailwind generates classes from source text, so runtime values usually fall back to inline styles. A style callback declares typed inputs instead, and each value binds to a CSS variable in a rule compiled ahead of time.

```tsx title="Progress.tsx"
export function Progress(props: Progress.Props) {
  return (
    <div
      className="h-2 bg-black"
      // Runtime values bypass utilities through inline styles
      style={{ width: `${props.value}%` }}
    />
  )
}

export declare namespace Progress {
  type Props = { value: number }
}
```

```tsx title="Progress.tsx"
import { style } from 'zyzz/default'

export function Progress(props: Progress.Props) {
  return <div {...styles.bar({ width: `${props.value}%` })} />
}

export declare namespace Progress {
  type Props = { value: number }
}

namespace styles {
  // The callback names each runtime value and its type
  export const bar = style((values: { width: `${number}%` }) => ({
    backgroundColor: 'black',
    height: 2,
    // Runtime widths sit outside the spacing scale, so they need !custom
    width: `${values.width} !custom`,
  }))
}
```

Calculate values in application code before passing them in. The callback cannot add properties or conditions at runtime.

## Custom Utilities

`@utility` and `@apply` share declarations across class lists. In Zyzz, a style holds the shared declarations, and `cx` combines it with other styles on the same element.

```css
@import 'tailwindcss';

/* A custom utility combines with built-in ones */
@utility focus-ring {
  &:focus-visible {
    outline: 2px solid currentColor;
    outline-offset: 2px;
  }
}
```

```tsx
export function SaveButton() {
  return (
    // The custom utility sits beside built-in utilities
    <button className="rounded-lg p-4 focus-ring">Save</button>
  )
}
```

```tsx title="SaveButton.tsx"
import { cx } from 'zyzz'
import { style } from 'zyzz/default'

export function SaveButton() {
  return (
    // cx combines applied styles, and later ones win conflicts
    <button {...cx(styles.button(), styles.focusRing())}>Save</button>
  )
}

namespace styles {
  export const button = style({ borderRadius: 'lg', padding: 4 })

  // A shared style replaces the custom utility
  export const focusRing = style({
    ':focus-visible': {
      outline: '2px solid currentColor',
      outlineOffset: '2px',
    },
  })
}
```

Separate JSX spreads replace each other's `className`, so combine styles with `cx` rather than spreading twice. Replace each `@apply` with the declarations its utilities expand to.

## Global Styles

Rules in Tailwind's `base` layer become `global` rules from `zyzz/web`. Keeping the `base` layer name places them in Tailwind's existing layer while both stylesheets load.

```css title="app.css"
@import 'tailwindcss';

/* Base styles sit below components and utilities */
@layer base {
  h1 {
    font-size: var(--text-2xl);
  }
}
```

```ts title="globals.ts"
import { global } from 'zyzz/web'

global({
  // The same layer keeps the same precedence
  '@layer base': {
    h1: { fontSize: '1.5rem' },
  },
})
```

Keep component styles in `style` definitions. See [Global Styles](/docs/guides/global-styles) and [Layers](/docs/guides/layers).

## Animations

Built-in animations map to tokens, such as `animation: 'spin'` for `animate-spin`. Custom theme animations and their `@keyframes` become `keyframes` from `zyzz/web`, which returns a reference for `animationName`.

```css
@import 'tailwindcss';

/* animate-fade-in reads this theme variable */
@theme {
  --animate-fade-in: fade-in 200ms ease-out;

  @keyframes fade-in {
    from {
      opacity: 0;
    }
  }
}
```

```tsx
export function Notice() {
  return (
    // motion-reduce: removes the animation for reduced motion
    <p className="animate-fade-in motion-reduce:animate-none">Saved.</p>
  )
}
```

```tsx title="Notice.tsx"
import { style } from 'zyzz/default'
import { keyframes } from 'zyzz/web'

// The same frames, referenced by animationName
const fadeIn = keyframes({ from: { opacity: 0 } })

export function Notice() {
  return <p {...styles.notice()}>Saved.</p>
}

namespace styles {
  export const notice = style({
    animationDuration: '200ms',
    animationName: fadeIn,
    animationTimingFunction: 'ease-out',
    '@media (prefers-reduced-motion: reduce)': { animationName: 'none' },
  })
}
```

## Remove Tailwind

After the last component migrates, remove Tailwind's import, integration, and packages. Removing the import also removes Preflight, so replace it deliberately, then recheck headings, lists, borders, and form controls. See [Reset](/docs/guides/reset).

```css title="app.css"
/* Importing Tailwind also installs Preflight */
@import 'tailwindcss';
```

```ts title="main.ts"
// The Zyzz reset replaces Preflight
import 'zyzz/reset.css'
```

Plugins such as `@tailwindcss/typography` and `@source inline()` safelists have no equivalent. Rewrite the CSS they generate as styles, or keep it as a separate stylesheet. Compare computed styles before and after each step, as described in [Testing & Troubleshooting](/docs/guides/testing).

## More

[Styling](/docs/guides/styling)

Compose definitions, override styles, and bind dynamic values.

[Conditions](/docs/guides/conditions)

Write state selectors, media and container queries, and element
relationships.

[Themes & Tokens](/docs/guides/themes)

Define tokens, variable sets, and color schemes in a typed config.

[Variants](/docs/guides/variants)

Define typed choices such as size and tone, with defaults and compounds.

[Global Styles](/docs/guides/global-styles)

Style document elements with selector maps beside component styles.

[Testing & Troubleshooting](/docs/guides/testing)

Test rendered styles, trace compilation failures, and check CSS delivery.
