State Variants

NEW
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
data-state=idle
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
Design
Build
Ship
Measure
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 — the names Tailwind uses) 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
Quarterly report
Edit
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
1 to 10 seats
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 the [dir="rtl"] & Tailwind emits — 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
Your order is on its way
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 Radix and Base UI spelling, so a selector written for either 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.