DEV Community

Cover image for CSS-Only Custom Elements for Accessibility (a11y)
Burton Smith
Burton Smith

Posted on

CSS-Only Custom Elements for Accessibility (a11y)

In CSS-Only Custom Elements for Layouts, I used undefined custom-element tags to define an API for layout primitives.

That technique can also be used to improve accessibility in your application.

Building visually-hidden

One of the most common accessibility patterns is visually hidden content that remains available to screen readers and other assistive technology.

An element that is merely transparent or moved off-screen is not always equivalent to hidden content. It can still affect layout, receive focus, or be exposed inconsistently. Use a deliberate clipping pattern:

/**
 * Visually hides content while keeping it available to assistive technology.
 */
visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;

  /* Restores visibility while the element or one of its descendants contains focus. */
  &:focus-within {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    overflow: visible;
    clip: auto;
    clip-path: none;
    white-space: normal;
  }
}
Enter fullscreen mode Exit fullscreen mode

The element can now carry context without changing the visual design:

<button type="button">
  <svg aria-hidden="true" viewBox="0 0 16 16"><!-- icon --></svg>
  <visually-hidden>Open navigation</visually-hidden>
</button>
Enter fullscreen mode Exit fullscreen mode

The button is still the semantic and interactive element. visually-hidden only changes how its child text is presented.

If the element contains a focusable descendant, :focus-within restores the wrapper automatically while that descendant has focus.

NOTE: Do not use this element for content that shouldn't be available to everyone. For that, use the native hidden attribute or display: none.

Building skip-link

Skip links are a useful example because they combine an accessible name, keyboard focus, and visual presentation:

/**
 * A keyboard-accessible link that becomes visible when focused.
 */
skip-link {
  display: block;
}

skip-link > a {
  position: absolute;
  top: 1rem;
  left: 1rem;
  z-index: 1000;
  transform: translateY(-200%);
  padding: 0.5rem 0.75rem;
  background: Canvas;
  color: CanvasText;
  border: 2px solid currentColor;
}

skip-link > a:focus-visible {
  transform: translateY(0);
}
Enter fullscreen mode Exit fullscreen mode
<skip-link>
  <a href="#main-content">Skip to main content</a>
</skip-link>

<header><!-- site navigation --></header>
<main id="main-content" tabindex="-1">
  <h1>Dashboard</h1>
</main>
Enter fullscreen mode Exit fullscreen mode

The target needs a stable id. tabindex="-1" allows a script or a browser to move focus to the main region without adding it to the normal tab sequence.

The custom element does not make the link a skip link. The nested native <a>, its href, and the target relationship do that. The custom element only gives the pattern a discoverable styling hook.

Building reduced-motion

Motion preferences can also be standardized without JavaScript. A common misconception is that prefers-reduced-motion: reduce means every animation and transition must be turned off. The goal is to minimize non-essential motion while preserving information and interaction.

A wrapper can provide a practical boundary for controlling descendant animations, especially in an area of the page that comes from a third-party component or code you do not own:

/**
 * Minimizes non-essential motion for its descendants when requested.
 */
reduced-motion {
  display: contents;
}

@media (prefers-reduced-motion: reduce) {
  reduced-motion,
  reduced-motion * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
  }
}
Enter fullscreen mode Exit fullscreen mode
<reduced-motion>
  <aside class="toast">Message sent</aside>
</reduced-motion>
Enter fullscreen mode Exit fullscreen mode

This is a policy boundary, not a guarantee that every animation has become accessible. Motion that communicates meaning may need an alternate presentation, and the preference should be respected by JavaScript-driven animation too.

What CSS cannot do

It is tempting to create elements such as <a11y-button>, <a11y-dialog>, or <a11y-live-region> and assume the names communicate their purpose. They do not.

CSS cannot:

  • add a button or other relevant ARIA role, click behavior, or keyboard activation to a generic element.
  • create an accessible name for an unlabeled control.
  • reliably announce changing content to a screen reader.
  • manage focus when a dialog opens or closes.
  • validate a widget's state or keep aria-expanded synchronized.

Use a native element first. Add ARIA only when native HTML cannot express the required relationship or state. Use JavaScript when interaction needs state, focus management, announcements, or event coordination.

Accessibility and the cascade

CSS-only elements have no shadow root, so their styles participate in the global cascade. That makes the API easy to override, but it also means a broad rule can interfere with consumer content.

Keep the base selector low-specificity when possible:

:where(visually-hidden, skip-link) {
  box-sizing: border-box;
}
Enter fullscreen mode Exit fullscreen mode

Scope descendant rules narrowly. Target specific interactive elements (a, button, etc.) rather than using broad universal selectors. A small selector surface is easier to audit.

Document the attributes and custom properties as part of the public API. A Custom Elements Manifest can describe CSS-only elements when the stylesheet includes the expected JSDoc comments, even though there is no JavaScript class.

When you should use these

CSS-only accessibility elements are useful when the primitive is about presentation or a reusable styling boundary:

  • A design system can standardize visually hidden content.
  • Templates can make skip links and reduced-motion boundaries discoverable.
  • Native HTML can remain responsible for meaning and interaction.
  • There is no JavaScript cost or custom-element upgrade lifecycle.

Conclusion

CSS-only custom elements can make accessibility patterns visible in markup, but they cannot make an inaccessible structure accessible by naming it.

The strongest design is a division of responsibility: native HTML provides semantics and interaction, ARIA fills specific gaps, JavaScript manages state and focus when required, and CSS handles presentation and user preferences.

When that contract is explicit, a tag and a stylesheet can provide a useful accessibility primitive without pretending to replace the platform.

Top comments (0)