Writing selectors that survive
Every element you might style carries one attribute naming what it is.
<section data-component="shopping-list-detail"> <div data-component="alert-dialog-footer"> <button data-component="button" data-variant="secondary">Cancel</button> </div></section>The name is what the thing is, and it is invariant. Anything carrying button styling is
data-component="button" — a plain <button>, a dropdown’s trigger, a dialog’s confirm action.
A hook that only some buttons have is not a hook.
An element is named for the thing it is, not the slot it fills: a dialog’s panel is
alert-dialog, not alert-dialog-content. “Content” describes a position in an implementation,
and a brand does not think in those.
Anything more specific is container + role
Section titled “Anything more specific is container + role”There is no second identity attribute.
/* every button */[data-component="button"] { … }
/* the cancel in a confirm dialog */[data-component="alert-dialog"] [data-component="button"][data-variant="secondary"] { … }That composition is stable because it names containers and roles rather than positions.
:first-child is not a styling hook — it breaks the day someone adds a button.
When there is no container, use state
Section titled “When there is no container, use state”Some elements have no enclosing element to qualify them. A menu trigger is a sibling of the menu it opens, so no ancestor selector reaches it. Reach those by the state they carry, which is more durable than a name because it is true of any element in that state:
[data-component="button"][aria-haspopup] { padding-inline: .5rem }[data-component="button"][aria-expanded="true"] { … }Reaching a whole feature surface
Section titled “Reaching a whole feature surface”The same attribute names feature components, so one selector reaches a surface:
[data-component="shopping-list-detail"] { --flow-gap: 2rem;}Which names a feature may claim
Section titled “Which names a feature may claim”A feature often wants its own name on an element it did not invent — login-form on a Form,
cart-empty on an EmptyState. That replaces the primitive’s name, so the question is whether
anything needed the name it replaced.
The test: does the theming model publish a selector that has to match this name?
Form controls are closed — button, input, textarea, select, select-trigger,
checkbox, radio-group-item. They are the family sized and shaped as a unit through
--control-* and --radius-base, so a feature may never take one:
<Select data-component="country-select"> {/* drops out of [data-component="select"] */}
<div data-component="country-select"> {/* correct: the feature's own wrapping element */} <Select />Everything else is the feature’s to claim. No token targets form, empty-state, heading
or card; [data-component="empty-state"] is a hook nobody writes, and cart-empty is one a
brand actually wants.
Do not reach for container + role to recover a closed name — an address form can grow a second select, and “the only select in this form” is exactly the positional fragility this model avoids.
Delegation
Section titled “Delegation”When a component renders through another element — <Button asChild><a href="…"> — the
innermost component’s own name wins. A Button inside a DialogTrigger renders
data-component="button", because the element is a button.
Hand-writing the attribute on the delegated child shadows it. That is a deliberate hole: the merge cannot tell a caller naming an element from a caller renaming one.
Icon-only buttons declare themselves
Section titled “Icon-only buttons declare themselves”[data-component="button"][data-icon-only] { padding-inline: 0; aspect-ratio: 1;}Do not infer it in CSS. A :has(> [data-component="icon"]) test misses a button whose content is
a glyph, and any :only-child predicate breaks silently the day a visually-hidden label is added.
Typography is CSS, not a component
Section titled “Typography is CSS, not a component”Features render semantic HTML — h1–h6, p, small, blockquote, code — with no typography
classes. There is no Text, Display or typography() primitive, and the one Heading primitive
carries no styling at all: it exists only to turn a level into a tag.
Style text per region, not per element:
[data-component="account-shell"] h2 { @apply text-base font-medium tracking-normal;}[data-component="account-shell"] h3 { @apply text-sm font-medium;}One rule replaces N utility classes and keeps the document outline honest. Reserve .prose for
running text and CMS bodies — that is the only context where “heading level tracks visual weight”
is actually true. Reach for a utility at a call site only for a genuine one-off.
Heading levels are explicit props, never inferred: a feature cannot know where you composed it,
and a context provider that auto-increments depth cannot be read in a Server Component. A view
takes one headingLevel and derives its subtree from it, so composing it under your own page title
means passing headingLevel={2}.

