# State Variants

_Box Kite 2.2.6 · a markdown copy of https://www.box-kite.dev/variants/_

Style an element by an attribute it carries, by what it contains, by where it sits, by a state it is not in — or by what an ancestor, a sibling, the browser or the device is doing.

## A state somebody else set is still a prop

`hover` and `checked` describe states the browser knows about. Everything else — a menu that is open, a row that is selected, a step that is loading — is a state _your_ code knows about, and until now the only way to style it was a ternary in the markup. The keys on this page put it back in CSS: five that add a fragment to the element's own selector, two that hang the rule off an ancestor or a sibling. One rule covers every element in that state, instead of a new class per render.

**The five keys**

```jsx
<Box
  dataAttr={{ 'state=open': { opacity: 1 }, loading: { cursor: 'wait' } }}   // [data-state="open"], [data-loading]
  ariaAttr={{ selected: { bgColor: 'indigo-500' } }}                        // [aria-selected="true"]
  has={{ ':checked': { borderColor: 'indigo-500' } }}                       // :has(:checked)
  not={{ hover: { opacity: 0.7 } }}                                         // :not(:hover)
  nth={{ odd: { bgColor: 'slate-50' } }}                                    // :nth-child(odd)
/>
```

## dataAttr — the attribute your own code writes

The record key is the attribute: `'state=open'` becomes `[data-state="open"]`, and a bare `'loading'` becomes `[data-loading]` — present with any value at all. The attribute itself goes in `props`, where every attribute goes.

**dataAttr**

```jsx
<Box
  props={{ 'data-state': state }}
  dataAttr={{
    'state=idle': { bgColor: 'slate-500' },
    'state=busy': { bgColor: 'amber-500' },
    'state=done': { bgColor: 'emerald-500' },
  }}
/>
```

## ariaAttr — the state a screen reader is already told about

Same grammar, one difference: a bare key means `="true"`, because that is what an ARIA state means. So `ariaAttr={{ selected: … }}` is `[aria-selected="true"]` and no second source of truth is invented — the attribute that makes the tab list correct is the one that colours it.

**ariaAttr**

```jsx
{['Overview', 'Usage', 'API'].map((tab) => (
  <Button
    key={tab}
    props={{ role: 'tab', 'aria-selected': tab === selected }}
    onClick={() => setSelected(tab)}
    ariaAttr={{ selected: { bgColor: 'indigo-500', color: 'white' } }}
  >
    {tab}
  </Button>
))}
```

## has — style a parent by what is inside it

`has` puts its key inside `:has()`, so a container can react to its own contents with no state lifted out of the DOM. The library already ships the common ones as pseudo-class props (`hasChecked`, `hasFocus`, `hasInvalid`); this is the general form for everything else.

**has**

```jsx
<Box has={{ 'input:checked': { borderColor: 'indigo-500', bgColor: 'indigo-50' } }}>
  <Checkbox label="I agree to the terms" />
</Box>
```

## not — the state you are not in

`not` is keyed by pseudo-class name rather than by a selector, so it stays typed and autocompletes: every key `hover`, `checked`, `disabled` and the rest already accept, minus the ones a `:not()` cannot hold. An attribute is negated the same way, with the prefix that says so — `not={{ 'data-loading': … }}`. It is the honest way to say "dim everything that is not the one being pointed at" without writing the positive rule twice.

**not**

```jsx
<Box group={{ 'deck/hover': { not: { hover: { opacity: 0.5 } } } }} />
```

## nth — where the element sits among its siblings

The fifth key is a position rather than a state: `first`, `last`, `only`, `odd`, `even`, an `An+B` formula, or any of those counted from the end — `'last 2'` is the second-to-last child. Striping a list and dropping the divider under its last row is two records and no index arithmetic in the markup, so a row does not have to know how many rows there are.

**nth**

```jsx
{['Design', 'Build', 'Ship', 'Measure'].map((step) => (
  <Box
    key={step}
    px={4}
    py={3}
    bb={1}
    borderColor="slate-500/20"
    nth={{ odd: { bgColor: 'slate-500/10' }, 'last 1': { bb: 0 } }}
  >
    {step}
  </Box>
))}
```

## group and peer — a state that belongs to somebody else

Every key above is about the element itself. `group` is about an _ancestor_ and `peer` about a preceding _sibling_: the record key is the state, either on the default class (`group`, `peer`) or on one you name, `'card/hover'`. The state vocabulary is `not`'s, so any pseudo-class works, and a `data-`/`aria-` prefix reaches an attribute the ancestor carries instead.

**group**

```jsx
<Flex className="card" ai="center" p={4} b={1} borderRadius={2}>
  <Box>Quarterly report</Box>
  <Box ml="auto" opacity={0} group={{ 'card/hover': { opacity: 1 } }}>
    Edit
  </Box>
</Flex>
```

**peer**

```jsx
<Checkbox
  className="agree"
  checked={agreed}
  onChange={(event) => setAgreed(event.target.checked)}
  label={<Box peer={{ 'agree/checked': { color: 'emerald-500' } }}>I agree to the terms</Box>}
/>
```

## The states the browser already knows

Eight more pseudo-class keys, each one a state the platform tracks and nothing else can see: `open` (a `<details>`, a `<dialog>`, a popover, a `<select>`'s picker), `placeholderShown`, `autofill`, `inRange`/`outOfRange`, `visited`, `target` and `inert`. They nest like `hover`, negate under `not`, and go on a group. Two of them come with a caveat worth knowing: `visited` takes _colour_ properties only — the browser refuses the rest, and lies about them, so that a page cannot read a reader's history — and `inert` matches the whole inert subtree, because inertness is inherited where the attribute is not.

**inRange / outOfRange**

```jsx
<Textbox
  type="number"
  value={amount}
  onChange={(event) => setAmount(event.target.value)}
  props={{ min: 1, max: 10, 'aria-label': 'Seats' }}
  b={2}
  borderStyle="solid"
  inRange={{ borderColor: 'emerald-500' }}
  outOfRange={{ borderColor: 'red-500' }}
/>
```

## pointerCoarse and pointerFine — what the device can do

The two device features sit beside the accessibility preferences (`motionReduce`, `forcedColors`, `contrastMore`) and behave exactly like a breakpoint: one `@media` block around one rule. A finger needs a bigger target than a mouse pointer, and a control that only appears on hover needs to be permanent where there is no hover at all. They rank _below_ the preferences — what the pointer can do is a fact about the device, and what the reader asked for still wins.

**pointerCoarse**

```jsx
<Button py={2} pointerCoarse={{ py: 3, fontSize: 16 }}>
  Add to cart
</Button>
```

## rtl and ltr — the direction the reader is going

The logical props do most of a translation on their own: `ps`/`pe`, `ms`/`me`, `bs`/`be`, `insetStart`/`insetEnd` and `borderRadiusStart`/`borderRadiusEnd` swap sides the moment `dir="rtl"` is set on any ancestor, because the browser resolves them and nothing has to re-render. These two keys are for what is left: an arrow that has to point the other way, a shadow that has to fall the other way. The selector is `:dir(rtl)` rather than an ancestor selector like `[dir="rtl"] &`— direction is a property of _this_ element, so a `<bdi>` or a `dir="auto"` that flipped one paragraph is seen. The consequence worth knowing: with no `dir` anywhere the document is left-to-right, so `ltr` matches. It is a state, not an attribute you have to write.

**One card, both directions**

```jsx
import { ArrowRight } from 'lucide-react';

<Flex
  props={{ dir }}
  ai="center"
  gap={3}
  ps={4}
  pe={3}
  py={3}
  bs={4}
  borderStyle="solid"
  borderColor="indigo-500"
  borderRadiusEnd={2}
>
  <Box flex1 fontSize={14}>
    Your order is on its way
  </Box>
  <Box color="indigo-500" rtl={{ flip: 'xAxis' }}>
    <Icon size={5}>
      <ArrowRight />
    </Icon>
  </Box>
</Flex>
```

## Everything else nests around them, in either direction

A variant is a fragment on the element's own compound selector, so it composes with all three of the other nesting kinds: a breakpoint or a preference wraps the rule, a theme or a group puts an ancestor in front of it, and a pseudo-class joins the same compound. The class name is built from the _set_ rather than the order, so `dataAttr={{ x: { not: … } }}` and `not={{ … : { dataAttr: { x } } }}` resolve to one class and one rule.

**Composing**

```css
/* md={{ dataAttr: { 'state=open': { hover: { color: 'red-500' } } } }} */

@media (min-width: 768px) {
  .md-hover-dataAttr-state\=open-color-red-500[data-state="open"]:hover { color: var(--red-500) }
}

/* No cascade rank of its own: .a[data-state="open"] is 0,2,0 against .b's 0,1,0,
   so the variant already outranks the plain class it overrides. */
```

## The exit, written as CSS instead of a ternary

`<Presence>` hands its child `{ 'data-state': 'open' | 'closed' }` — the conventional spelling, so a selector written against it elsewhere works here. With `dataAttr` the whole entrance and exit is two records and no conditional value in the markup: the element says where it is, and the stylesheet says what that looks like.

**Presence + dataAttr**

```jsx
<Presence present={shown}>
  {({ ref, props }) => (
    <Box
      ref={ref}
      props={props}
      transitionDuration={320}
      startingStyle={{ opacity: 0, translateY: -2 }}
      dataAttr={{
        'state=open': { opacity: 1, translateY: 0 },
        'state=closed': { opacity: 0, translateY: -2 },
      }}
    />
  )}
</Presence>
```

## What this library sets for you

Two attributes come out of the library itself, and both are documented targets rather than internals: `data-state="open" | "closed"` on whatever `<Presence>` is holding — which is every layer built on it, so `Tooltip`, the `Dropdown` popup and the DataGrid's column menu all carry it — and `data-theme` on the element `Box.Theme` writes to. Everything else is yours to set.

## A key it cannot parse produces nothing

The record key becomes part of a selector, so it is validated before it gets there: an attribute name that is not one, a value carrying a quote, an unbalanced `:has()`. A key that fails drops its whole block — no rule and no class name, exactly what an unmatched prop value does. A typo is invisible rather than a selector nobody wrote, and nothing is ever left carrying a class with no rule behind it.

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

