DEV Community

Cover image for CSS-Only Custom Elements
Burton Smith
Burton Smith

Posted on

CSS-Only Custom Elements

There's a quiet trend picking up steam in the web components world: developers using custom-element-shaped markup that never calls customElements.define(). No class, no lifecycle callbacks, no component JavaScript at all. Just a tag name and a stylesheet.

At first glance, this feels like it's missing the point of custom elements. However, an undefined custom-element name is still valid HTML markup that the browser can select, style, and compose using nothing but CSS. For many UI patterns, that's all you need.

Why Go CSS-Only?

Zero JavaScript cost

  • No component bundle, no hydration, and no custom-element upgrade work; there's nothing to run until the CSS applies.
  • The browser treats the element as an ordinary undefined element, although CSS selector matching and layout complexity still affect rendering as usual.

Progressive enhancement by default

  • If your CSS fails to load, your code is still functional. The custom element itself has no implicit semantics, but semantic children such as headings and links retain theirs.
  • If you later decide a component needs interactivity, you can define the same tag with customElements.define() without changing its markup. That changes its behavior and lifecycle, so treat the tag and attributes as a deliberate contract.
  • The tag name becomes a stable contract between your library and its consumers, independent of implementation.

Meaningful tag names as an API

  • <my-badge variant="danger"> reads better in markup than <span class="badge badge--danger">.
  • It makes your design system's vocabulary visible directly in the DOM: in dev tools, in view-source, in a designer's handoff notes.

A natural home for CSS custom properties

  • Because the element is just a styling target, it's a clean place to expose your component's public API as custom properties.
  • Since the element has no shadow root, these properties work like any ordinary element's: set once, inherited by descendants, and easy for consumers to override.

Attributes for Variants and Slots for Composition

The two workhorses of CSS-only components are attribute selectors for state and variants, and slot-like attributes for composition. Neither requires JavaScript. The slot attribute here isn't the shadow-DOM slotting mechanism; it's just a convention: an element with a slot attribute that your CSS targets to control layout and appearance.

NOTE: These examples use CSS nesting, including &[variant] and nested descendant rules. If you support older browsers, you can use flattened CSS.

Here's a badge component built this way:

/**
 * A badge styled entirely with CSS.
 */
my-badge {
  /** Inner spacing. */
  --badge-padding: 4px;
  /** Surface color. */
  --badge-bg-color: lightgray;
  /** Text and icon color. */
  --badge-fg-color: black;

  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: var(--badge-padding);
  background-color: var(--badge-bg-color);
  color: var(--badge-fg-color);
  border-radius: 4px;
  font-size: 0.875rem;
  line-height: 1;

  &[variant="danger"] {
    --badge-bg-color: red;
    --badge-fg-color: white;
  }

  &[variant="success"] {
    --badge-bg-color: seagreen;
    --badge-fg-color: white;
  }

  /** Positions the start icon before the badge text */
  [slot="icon-start"] { order: -1; }
  /** Positions the end icon after the badge text */
  [slot="icon-end"] { order: 1; }
}
Enter fullscreen mode Exit fullscreen mode

And the markup it targets:

<my-badge variant="danger">
  <svg slot="icon-start" width="12" height="12" aria-hidden="true"><!-- icon --></svg>
  Payment failed
  <svg slot="icon-end" width="12" height="12" aria-hidden="true"><!-- icon --></svg>
</my-badge>
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

  • display is set explicitly. Undefined custom elements default to inline layout. Without this declaration, a component that needs flex, grid, or block layout will not lay out correctly. Always declare the intended display mode.
  • order does the layout work slotting would otherwise need JS for. The start icon uses order: -1, the text remains at its default order: 0, and the end icon uses order: 1. In this markup, the source order already agrees, so the rules are mostly there to show the technique; they only matter if the markup is authored out of order, which is exactly the case the next section covers.
  • Custom CSS properties are part of the public API. A consumer who wants a purple badge doesn't need a new variant attribute; they can just set --badge-bg-color inline or in their own stylesheet.
  • Decorative icons/elements should be hidden from assistive technology. The example SVGs carry aria-hidden="true". If an icon is purely illustrative, it should not be announced; if it conveys meaning, give it an accessible alternative.

Choosing the Right Selector

Once you start writing rules like my-badge { ... }, it's worth being deliberate about which selector form you reach for, since the three options behave differently and are easy to mix up.

Plain tag selector (my-badge) - the default

Selecting the tag name like in the example above works well and should probably be the default for styling your components.

  • my-badge { ... } is a normal type selector at specificity (0,0,1), exactly like div or span.
  • Nothing about it being a custom element changes how selectors work.

:where(my-badge)

If you want to lower the selector's specificity to make everything easily overrideable, use the :where() selector.

  • :where() always contributes (0,0,0) to specificity, no matter what's inside it.
  • Wrapping the base rule in :where() means a consumer can override any part of it with a single class, such as .compact-badge { padding: 2px; }, instead of fighting your selector's specificity or reaching for !important.
  • It's also a cheap way to group several element names under one low-specificity reset:
:where(my-badge, my-chip, my-tag) {
  box-sizing: border-box;
  font-family: inherit;
}
Enter fullscreen mode Exit fullscreen mode

:is(my-badge)

If you're not combining selectors, using the :is() selector is not worth it.

  • :is() takes on the highest specificity among its arguments, so wrapping a lone selector in it, like :is(my-badge) { ... }, behaves identically to my-badge { ... } but adds a layer of indirection for no benefit.
  • Where :is() earns its place is in selector lists, matching either the custom element or a class-based fallback in one rule:
:is(my-badge, .my-badge) [slot="icon-start"] {
  padding: 2px;
}
Enter fullscreen mode Exit fullscreen mode

Rule of Thumb

  • By default, use plain selectors (my-badge, my-card).
  • If you want all aspects of the custom element to be easily overrideable, wrap the base rule in :where().
  • Reach for :is() only when the rule genuinely needs to match more than one selector at once.

Multiple Slots and Boolean Attributes

The same approach scales to richer components. A card component might look like this:

my-card {
  display: grid;
  grid-template-areas:
    "media"
    "header"
    "body"
    "footer";
  gap: 12px;

  &[compact] {
    gap: 4px;
  }

  [slot="media"]  { grid-area: media; }
  [slot="header"] { grid-area: header; font-weight: 600; }
  [slot="body"]   { grid-area: body; }
  [slot="footer"] { grid-area: footer; }
}
Enter fullscreen mode Exit fullscreen mode
<my-card compact>
  <img slot="media" src="..." alt="" />
  <h3 slot="header">Release notes</h3>
  <p slot="body">Version 2.4 adds dark mode support.</p>
  <a slot="footer" href="/changelog">Read more</a>
</my-card>
Enter fullscreen mode Exit fullscreen mode
  • Boolean attributes like compact work exactly like variant. You're just using presence-based selectors ([compact]) instead of value matching.
  • Any child without a slot attribute is still a grid item and gets auto-placed, usually into an implicit row below the named areas. Keep every child in one of the expected slots, or design the template with a deliberate spot for extra content.

Accessibility Concerns With Slot-Based Reordering

The [slot="icon-start"] { order: -1; } pattern is one of the most useful tricks in this approach, but it's also the one most likely to introduce accessibility problems.

order Changes Visual Position, Not DOM Order

  • Flexbox and grid's order property affects layout and visual order: where things appear on screen.
  • It does not move elements in the underlying DOM. The programmatic reading order and default sequential focus order generally remain based on the source order.

For example, if your markup is authored as:

<my-badge>
  <svg slot="icon-end">...</svg>
  Payment failed
  <svg slot="icon-start">...</svg>
</my-badge>
Enter fullscreen mode Exit fullscreen mode

...and your CSS uses [slot="icon-start"] { order: -1; } and [slot="icon-end"] { order: 1; } to reflow the icons around the text; the visual order becomes icon-start, "Payment failed," then icon-end. That is the reverse of the source order: icon-end, then "Payment failed," then icon-start. Assistive technology generally encounters content in source order, regardless of how it appears on screen, so sighted users and screen reader users can end up with two different experiences of the same component.

Tab Order Is Affected the Same Way

  • If any slot-like content is interactive, such as a button or link in a slot="action", keyboard users generally tab through elements in DOM order, not visual order position.
  • A reordered interactive element can end up focused in a sequence that doesn't match what's on screen, which is disorienting and can fail WCAG 2.4.3 (Focus Order) under stricter interpretations.

The same warning applies to CSS Grid. grid-template-areas and explicit grid placement can produce a visual order that differs from source order, even when no order property appears in the stylesheet. Keep the DOM sequence meaningful at every breakpoint.

Slots Don't Add Landmark or Grouping Semantics

  • Assistive technology has no awareness that these slotted elements are grouped or sequenced as a component, because the attribute carries no semantics on its own.
  • Any semantic relationship, such as an icon belonging with a label, must come from the markup itself (for example, wrapping in a <span> with appropriate aria- attributes, or relying on natural adjacency) rather than from the slot convention.

Practical Guidance

  • Author the markup in the order you want it read (icon, text, icon), and use order only for a remaining cosmetic gap.
  • Test with a screen reader, or at minimum read the DOM top to bottom, whenever order is used. A visually correct page is not a proxy for accessibility.
  • If icon-start and icon-end truly need to swap for RTL languages or similar, prefer logical properties and flex-direction changes scoped to :dir(rtl) over ad hoc order values, so the intent stays explicit rather than incidental.

NOTE: Real shadow-DOM slots don't automatically fix accessibility either, but they do render in the specified slot order.

@scope as an Alternative to Shadow DOM

Since CSS-only custom elements have no shadow DOM, everything discussed so far, including plain selectors, :is(), and :where(), still operates in the global cascade. @scope gives you a way to approximate some of shadow DOM's containment without ever leaving regular CSS, and it's now solid enough across browsers to reach for in production.

The syntax defines a scope root and, optionally, a lower boundary (a "scope limit") past which the rules stop applying:

@scope (my-card) to ([slot]) {
  h3 { font-weight: 600; }
  p  { color: gray; }
}
Enter fullscreen mode Exit fullscreen mode

This says: apply these rules to descendants of my-card, but stop at any slotted content. If a consumer's slot="body" happens to contain its own <h3> or <p>, your component's internal styles won't leak into it. That's a meaningfully different guarantee than a plain descendant selector like my-card h3 { ... }, which would apply everywhere inside the element regardless of whether that content came from the component author or the consumer.

Properties Worth Knowing

  • The scope root does not add normal selector specificity. Selectors inside the block still contribute their own specificity, while scoped cascade proximity provides an additional way to resolve competing scoped rules. @scope is not a blanket guarantee that every consumer rule will override every component rule.
  • @scope doesn't create a stacking context, containment, or z-index isolation. It only limits which elements a selector can match. Custom properties, counters, and other cascade-level values still flow across scope boundaries as normal.
  • Nested @scope blocks are supported. A nested scope can establish a more local boundary inside an existing scope, which is useful for component hierarchies without reaching for BEM-style naming at every level.

For CSS-only custom elements specifically, @scope can be a nice complement to :where(). Use :where() on the base type selector to keep specificity low and overridable from the outside, and use @scope internally to keep your component's own descendant styles from bleeding into slotted content that the consumer controls.

Browser Support

@scope reached Baseline "Newly available" status in late 2025, and browsers that don't understand the @scope at-rule ignore the entire block rather than applying the rules unscoped. If broad compatibility matters, duplicate critical rules outside the block as an unscoped fallback, or feature-detect with @supports at-rule(@scope).

Documentation

A tag name and a stylesheet are enough for the browser, but not for the tools around your component. If the element never makes it into the Custom Elements Manifest (CEM), documentation generators can't list it and framework integrations can't type it or validate it. In a JSX or TSX template, for example, <my-badge variant="danger"> will error because the compiler has no type for the tag — and even where it doesn't, there's no type-safety on attributes or slots and no API validation to catch a typo or a removed variant. The same gap affects editor completion, docs sites, and design-system catalogs.

Because there is no JavaScript class to analyze, the metadata has to be generated from the stylesheet, which is why the CEM Generator includes a built-in CSS detector. It reads JSDoc-style comments and custom-element selectors — including :where(), :is(), and @scope — and emits attributes, slots, and CSS custom properties into the manifest, so a CSS-only element flows through the same documentation and integration pipeline as any JavaScript-defined component.

Other Things to Keep in Mind

  • No shadow DOM means no style encapsulation. Without a shadow root, there's no hard boundary against collisions. Namespacing your tag names (my-badge rather than badge) and keeping selectors tight help; @scope narrows the gap but doesn't close it.
  • No implicit semantics. An undefined custom element has no ARIA role and doesn't match :defined. Use native elements where meaning matters; add explicit role or ARIA only when necessary.
  • Slots are just a naming convention. Because there's no shadow root, slot="icon-start" doesn't trigger any browser-level projection. It's just an attribute your CSS happens to select on. That's a feature (zero-cost, no JS), but it's worth stating clearly so readers don't assume shadow-DOM slotting behavior.

Conclusion

Many components don't need JavaScript to be useful. A tag name and a stylesheet give you a contract, a vocabulary, and a styling surface. If a component eventually needs more, the contract is already written. The only thing standing between you and a fully defined custom element is a definition call.

Top comments (0)