Skip to content

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.

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"] { … }

The same attribute names feature components, so one selector reaches a surface:

[data-component="shopping-list-detail"] {
--flow-gap: 2rem;
}

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.

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.

[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.

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}.