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 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
<Boxprops={{'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.
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
<Boxhas={{'input:checked':{borderColor:'indigo-500',bgColor:'indigo-50'}}}><Checkboxlabel="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.
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.
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.
<Checkbox
className="agree"
checked={agreed}
onChange={(event)=>setAgreed(event.target.checked)}
label={<Boxpeer={{'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.
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
<Buttonpy={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';<Flexprops={{ dir }}ai="center"gap={3}ps={4}pe={3}py={3}bs={4}borderStyle="solid"borderColor="indigo-500"borderRadiusEnd={2}><Boxflex1fontSize={14}>
Your order is on its way
</Box><Boxcolor="indigo-500"rtl={{flip:'xAxis'}}><Iconsize={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.
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.