

# Variants

Define typed component choices, apply defaults, and combine matching selections without writing class-name logic.

## Overview

A variant is a named choice that changes how a component looks, such as a compact or regular size, or a primary or quiet tone. Choices are grouped into axes, and each call selects at most one choice per axis.

`variants` defines one element's axes, choices, defaults, and the rules that apply when choices combine. The compiler emits every choice ahead of time, so selecting one only switches the classes and `data-*` attributes on the element.

Start with [Getting Started](/docs/introduction/getting-started) to connect the compiler. Import `variants` from `zyzz` for literal CSS values, or from a project config for its tokens.

```tsx title="Badge.tsx"
import { variants } from 'zyzz'

export function Badge() {
  return <span {...styles.badge({ tone: 'success' })}>Paid</span>
}

namespace styles {
  export const badge = variants({
    base: { borderRadius: '999px', padding: '2px 8px' },
    variants: {
      tone: {
        neutral: { backgroundColor: '#eee', color: '#333' },
        success: { backgroundColor: '#e6f6ec', color: '#05612c' },
      },
    },
  })
}
```

## Choices and Defaults

Put declarations shared by every choice in `base`. Group alternatives under a named axis in `variants`, then choose the initial values with `defaultVariants`.

```tsx
import { variants } from 'zyzz'

export function SaveButton() {
  return <button {...styles.button({ size: 'compact' })}>Save</button>
}

namespace styles {
  export const button = variants({
    base: {
      border: '1px solid currentColor',
      borderRadius: '6px',
      display: 'inline-flex',
    },
    defaultVariants: { size: 'regular', tone: 'primary' },
    variants: {
      size: {
        compact: { padding: '4px 8px' },
        regular: { padding: '8px 16px' },
      },
      tone: {
        primary: { backgroundColor: '#06c', color: 'white' },
        quiet: { backgroundColor: 'transparent', color: '#06c' },
      },
    },
  })
}
```



This button selects `compact` and keeps the default `primary` tone. Calls return a stable class and selection attributes such as `data-size`, so spread the full result onto the element.

Axis names become `data-*` attributes. They use lowercase letters, digits, and hyphens, start with a letter, and cannot be `class`, `conditions`, `key`, `ref`, `style`, or `vars`.

## Type Component Props

`Props.Variants` derives the allowed selections from the definition. Component behavior such as `disabled` stays on the element rather than passing through the styling call.

```tsx
import type { ReactNode } from 'react'
import { type Props, variants } from 'zyzz'

export function Button(props: Button.Props) {
  const { children, disabled, ...rest } = props 

  return (
    <button disabled={disabled} {...styles.button(rest)}>
      {children}
    </button>
  )
}

export declare namespace Button {
  type Props = Props.Variants<typeof styles.button> & {
    children: ReactNode
    disabled?: boolean | undefined
  }
}

namespace styles {
  export const button = variants({
    defaultVariants: { size: 'regular' },
    variants: {
      size: {
        compact: { padding: '4px 8px' },
        regular: { padding: '8px 16px' },
      },
    },
  })
}
```



`Props.Variants` covers everything the call accepts: the `size` selection, the `className`, `style`, and `vars` overrides, and `conditions` when the recipe declares them. The component reads `children` and `disabled` and forwards the rest to the call, so callers can pass overrides along with a size.

Call sites complete the declared choices, and an undeclared value such as `size="huge"` fails the type check:

```tsx title="Toolbar.tsx"
import { Button } from './Button.js'

export function Toolbar() {
  return <Button size="">Save</Button>
  // completions: regular, compact
}
```

## Suppress a Default

An omitted or `undefined` selection uses the default. Pass `null` to suppress an axis and its default. Boolean choices use actual booleans, and `false` selects the `false` choice.

```ts
import { variants } from 'zyzz'

namespace styles {
  export const label = variants({
    base: { opacity: 1 },
    defaultVariants: { muted: true },
    variants: {
      muted: {
        true: { opacity: 0.5 },
        false: { opacity: 1 },
      },
    },
  })
}

styles.label()
styles.label({ muted: undefined })
styles.label({ muted: false })
styles.label({ muted: null })
```



| Selection | Applied choice | Attribute |
| --- | --- | --- |
| Omitted or undefined | Default true choice | data-muted="true" |
| false | false choice | data-muted="false" |
| null | No choice, and base still applies | No data-muted attribute |

## Combine Matching Choices

Use `compoundVariants` when styling depends on more than one axis. Every axis in `when` must match, and an array matches any listed choice for that axis.

```ts
import { variants } from 'zyzz'

namespace styles {
  export const button = variants({
    base: { border: '1px solid transparent' },
    compoundVariants: [
      {
        when: { size: ['compact', 'regular'], tone: 'quiet' },
        style: { borderColor: 'currentColor' },
      },
    ],
    defaultVariants: { size: 'regular', tone: 'primary' },
    variants: {
      size: {
        compact: { padding: '4px 8px' },
        regular: { padding: '8px 16px' },
      },
      tone: {
        primary: { backgroundColor: '#06c', color: 'white' },
        quiet: { backgroundColor: 'transparent', color: '#06c' },
      },
    },
  })
}

styles.button({ tone: 'quiet' })
```



The quiet button receives a visible border at either size, and compounds also match defaults. For conflicting declarations, precedence is `base`, then axes in declaration order, then compounds in array order, within matching contexts and importance. Selection attributes add no selector specificity.

## Select with Conditions

Declare named conditions when a query should switch the selected choice, then pass overrides under `conditions` at the application site. CSS evaluates the queries, so the call does not read the viewport or install listeners.

```tsx
import { variants } from 'zyzz'

export function ResponsiveButton() {
  return (
    <button {...styles.button({ conditions: { wide: { size: 'regular' } } })}>
      Save
    </button>
  )
}

namespace styles {
  export const button = variants({
    base: { borderRadius: '6px' },
    conditions: { wide: '@media (width >= 768px)' },
    defaultVariants: { size: 'compact' },
    variants: {
      size: {
        compact: { padding: '4px 8px' },
        regular: { padding: '8px 16px' },
      },
    },
  })
}
```



This button uses `compact` below 768px and `regular` when `wide` matches. Switching choices removes declarations unique to the previous choice, and compounds match the effective selections. Each axis resolves its overrides separately:

* **Several matches:** The last matching condition in recipe declaration order wins.
* **Missing or `undefined`:** Inherits the earlier selection.
* **`null`:** Suppresses the axis while the condition matches.

Selection conditions support `@media` and `@supports`, with up to eight named conditions per recipe. Container and selector queries cannot switch choices. Use ordinary [conditions](/docs/guides/conditions) inside a choice when only its declarations need to change.

## Bind Dynamic Choices

When one choice needs a per-instance value, define that choice with a typed callback. Select it with an object containing the choice name and its payload.

```tsx
import { variants } from 'zyzz'

export function CustomButton() {
  return (
    <button {...styles.button({ size: { custom: { padding: '20px' } } })}>
      Save
    </button>
  )
}

namespace styles {
  export const button = variants({
    compoundVariants: [
      { when: { size: 'custom' }, style: { fontWeight: 600 } },
    ],
    defaultVariants: { size: { custom: { padding: '12px' } } },
    variants: {
      size: {
        compact: { padding: '4px' },
        custom: (values: {
          padding: `${number}px`
        }): { padding: `${number}px` } => ({
          padding: values.padding,
        }),
      },
    },
  })
}
```



The compiler creates fixed CSS-variable slots for the callback's fields. Applying the choice binds `20px` to its slot without adding CSS rules, and compounds match the name `custom` regardless of the padding value.

Payload fields must be required scalars with explicit types. Defaults need complete static payloads, so a bare `size: 'custom'` is invalid. Conditional selections accept the same payload shape, with separate slots for each condition.

## Compose Variants

Use `cx` when combining a recipe with another generated style. Separate JSX spreads replace props and can lose the recipe's class, attributes, or variable bindings.

```tsx
import { cx, style, variants } from 'zyzz'

export function FocusedButton() {
  return (
    <button {...cx(styles.button({ size: 'compact' }), styles.focusRing())}>
      Save
    </button>
  )
}

namespace styles {
  export const button = variants({
    variants: { size: { compact: { padding: '4px 8px' } } },
  })

  export const focusRing = style({
    selectors: {
      '&:focus-visible': {
        outline: '2px solid currentColor',
        outlineOffset: '2px',
      },
    },
  })
}
```



Styling calls also accept `className`, `style`, and `vars` overrides, such as `styles.button({ size: 'compact', style: { opacity: 0.5 } })`. TypeScript checks application inputs, and runtime selection does not validate them.

## More

[Conditions](/docs/guides/conditions)

Style states, queries, and relationships inside each choice.

[Styling](/docs/guides/styling)

Compose definitions, override styles, and bind dynamic values.

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

Bind recipes to project tokens through a typed config.

[Publish Libraries](https://github.com/wevm/zyzz/blob/main/docs/guides/compilation.md#publish-libraries)

Publish variant definitions with their stylesheets and contracts.
