Originally published on tamiz.pro.
In the modern web development landscape, JavaScript has become the default tool for almost every interaction. While libraries like React and Vue abstract away much of the DOM manipulation, they also introduce overhead, hydration costs, and runtime dependencies. However, the CSS specification has evolved rapidly in recent years, introducing powerful selector patterns that allow developers to manage complex state-driven interactions entirely within the stylesheet.
This tutorial explores advanced CSS selector patterns—specifically :has(), :is(), :where(), and sibling combinators—that enable sophisticated UI animations and interactivity without a single line of JavaScript. By the end, you will be able to build interactive accordions, complex dropdowns, and animated modals using purely declarative CSS.
Table of Contents
- 1. The Case for CSS-Only Interactivity
- 2. Prerequisites and Browser Support
- 3. The :is() and :where() Functional Pseudo-Classes
- 4. The Revolutionary :has() Selector (The Parent Selector)
- 5. Practical Example: State-Driven Accordion
- 6. Advanced Pattern: The "Sticky" Radio Button Hack
- 7. Visual Feedback: :focus-visible and :focus-within
- 8. Performance Considerations
- 9. Frequently Asked Questions
1. The Case for CSS-Only Interactivity
Why move interactivity out of JavaScript? It is not about eliminating JavaScript entirely, but about offloading specific responsibilities. JavaScript is imperative; it tells the browser how to change state step-by-step. CSS is declarative; it tells the browser what the state should look like.
When you use CSS for interactivity:
- Reduced Bundle Size: You eliminate JavaScript libraries or custom hooks responsible for managing UI state.
- Better Performance: CSS animations and transitions are handled by the browser’s compositor thread, often resulting in smoother 60fps interactions without triggering layout thrashing.
- Accessibility: Native HTML elements (like
<details>,<summary>, or<input type="checkbox">) carry inherent accessibility semantics. CSS-only solutions that leverage these elements remain accessible by default, whereas custom JS-driven widgets often require manual ARIA management. - Resilience: If JavaScript fails to load or throws an error, CSS-based UI structures still function. The interface degrades gracefully rather than breaking entirely.
2. Prerequisites and Browser Support
Before diving into code, it is crucial to understand the support landscape. While these features are powerful, they are relatively new compared to traditional CSS properties.
-
:has(): Supported in all modern browsers (Chrome 105+, Safari 15.4+, Firefox 121+). This is the most critical selector for this tutorial. -
:is()/:where(): Supported in all modern browsers (Chrome 88+, Safari 15.4+, Firefox 88+). -
transition-behavior: Useful for animating properties that don't naturally interpolate (likedisplay), supported in Chromium-based browsers and Safari.
For production environments, always check Can I Use for the latest data. If you must support older browsers, consider a JavaScript fallback or progressive enhancement strategies.
3. The :is() and :where() Functional Pseudo-Classes
These two selectors streamline complex selector lists, but they serve slightly different purposes regarding specificity.
:is() (The :matches() successor)
The :is() pseudo-class matches any element that can be selected by one of the selectors in its argument list. It is essentially a way to reduce repetition.
Without :is():
h1:focus-visible,
h2:focus-visible,
h3:focus-visible {
outline: 2px solid blue;
outline-offset: 2px;
}
With :is():
:is(h1, h2, h3):focus-visible {
outline: 2px solid blue;
outline-offset: 2px;
}
Specificity: The specificity of :is() is determined by the most specific selector in its argument list. In the example above, if h1 had a class attached elsewhere, it would inherit that higher specificity.
:where() (The Zero-Specificity Selector)
:where() works exactly like :is() but always has a specificity of zero. This is invaluable when you want to override global styles without fighting specificity wars.
/* This will easily be overridden by other styles due to 0 specificity */
:where(ul, ol) {
margin: 0;
padding-left: 0;
}
4. The Revolutionary :has() Selector (The Parent Selector)
The :has() pseudo-class is a game-changer for CSS-only interactivity. Often called the "parent selector," it allows you to select a parent element based on the state of its children. Prior to :has(), CSS was strictly top-down; you could style children based on parents, but never parents based on children.
Syntax
parent:has(child) {
/* Styles applied to parent if child exists and matches */
}
Common Use Cases
- Styling a form field if it has an error:
.form-group:has(.error) { border-color: red; } - Styling a card if any image inside is hovered:
.card:has(img:hover) { box-shadow: ...; } - Creating an open state for a dropdown:
.dropdown:has(.menu:focus-within) { display: block; }
This capability enables state-driven UIs where the container’s appearance reacts to internal changes, eliminating the need for JavaScript to toggle classes on the parent.
5. Practical Example: State-Driven Accordion
Let’s build a multi-section accordion that expands and collapses using only HTML and CSS. We will use the <details> and <summary> elements, enhanced with :has() for smooth animation and better styling control.
The HTML Structure
<div class="accordion">
<details class="accordion-item">
<summary class="accordion-header">Section 1</summary>
<div class="accordion-content">
<p>Content for section 1 goes here.</p>
</div>
</details>
<details class="accordion-item">
<summary class="accordion-header">Section 2</summary>
<div class="accordion-content">
<p>Content for section 2 goes here.</p>
</div>
</details>
</div>
The CSS Logic
By default, <details> elements have a closed attribute when collapsed. We can use :has() to target the open state.
.accordion-item {
border: 1px solid #e0e0e0;
margin-bottom: 1rem;
border-radius: 8px;
overflow: hidden;
}
.accordion-header {
padding: 1rem;
background: #f5f5f5;
cursor: pointer;
list-style: none; /* Hide default triangle */
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
}
/* Hide default marker in Webkit browsers */
.accordion-header::-webkit-details-marker {
display: none;
}
/* Custom arrow icon */
.accordion-header::after {
content: '+';
font-size: 1.5rem;
transition: transform 0.3s ease;
}
.accordion-item[open] .accordion-header::after {
transform: rotate(45deg); /* Rotate + to form an x */
}
.accordion-content {
padding: 0 1rem;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out, padding 0.3s ease;
}
/* When the details element is open, expand the content */
.accordion-item[open] .accordion-content {
max-height: 500px; /* Arbitrary large number, or use grid trick */
padding: 1rem;
}
While this works, max-height animations can be janky if the content height is unknown. A more robust modern approach uses grid and :has().
Improved Animation with Grid and :has()
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.accordion-item:has([open]) .accordion-content {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
This approach allows the height to animate smoothly based on the content's intrinsic size, without needing JavaScript to calculate pixel values.
6. Advanced Pattern: The "Sticky" Radio Button Hack
For tabbed interfaces or multi-step forms where only one panel is visible at a time, the "Radio Button Hack" is a classic CSS-only technique. By hiding radio inputs and styling their labels, we can create interactive tabs.
HTML
<div class="tab-container">
<input type="radio" name="tabs" id="tab1" checked>
<input type="radio" name="tabs" id="tab2">
<input type="radio" name="tabs" id="tab3">
<nav class="tab-nav">
<label for="tab1">Tab 1</label>
<label for="tab2">Tab 2</label>
<label for="tab3">Tab 3</label>
</nav>
<div class="tab-content">
<div class="panel">Content for Tab 1</div>
<div class="panel">Content for Tab 2</div>
<div class="panel">Content for Tab 3</div>
</div>
</div>
CSS Logic
We use the general sibling combinator (~) to style the panels based on which radio button is checked.
/* Hide radio buttons */
.tab-container input[type="radio"] {
position: absolute;
opacity: 0;
}
/* Style the active tab label */
.tab-container label {
padding: 10px 20px;
cursor: pointer;
background: #ddd;
margin-right: 2px;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
.tab-container input:checked + label,
.tab-container label:hover {
background: #fff;
font-weight: bold;
}
/* Hide all panels by default */
.tab-content .panel {
display: none;
padding: 20px;
border: 1px solid #ddd;
}
/* Show the panel corresponding to the checked radio */
#tab1:checked ~ .tab-content .panel:nth-of-type(1),
#tab2:checked ~ .tab-content .panel:nth-of-type(2),
#tab3:checked ~ .tab-content .panel:nth-of-type(3) {
display: block;
}
Making it Smoother with :has()
We can replace the display: none / display: block switch with a fade-in animation using :has() to target the container.
.tab-container {
position: relative;
}
.panel {
position: absolute;
top: 0;
left: 0;
width: 100%;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s;
}
/* When the container has a specific radio checked, show the corresponding panel */
.tab-container:has(#tab1:checked) .panel:nth-of-type(1),
.tab-container:has(#tab2:checked) .panel:nth-of-type(2),
.tab-container:has(#tab3:checked) .panel:nth-of-type(3) {
opacity: 1;
visibility: visible;
transform: translateY(0);
position: relative; /* Allow flow when visible */
}
This creates a smooth transition between tabs without JavaScript.
7. Visual Feedback: :focus-visible and :focus-within
Accessibility is paramount. While :hover indicates interactivity to mouse users, keyboard users need visual cues. :focus-visible ensures that focus styles only appear when the user is navigating with a keyboard (or assistive technology), hiding them for mouse clicks.
:focus-within
This pseudo-class matches an element if any of its descendants are focused. This is incredibly useful for form validation states.
<div class="input-group">
<label for="email">Email</label>
<input type="email" id="email">
<span class="error-message">Invalid email format</span>
</div>
.input-group {
position: relative;
margin-bottom: 1rem;
}
.error-message {
position: absolute;
bottom: -20px;
left: 0;
color: red;
font-size: 0.875rem;
opacity: 0;
transform: translateY(-5px);
transition: opacity 0.2s, transform 0.2s;
}
/* Show error if the input is invalid AND focused, or if it has been interacted with */
.input-group:has(input:invalid:focus-within) .error-message {
opacity: 1;
transform: translateY(0);
}
/* Optional: Show error even if not focused, but only after user has typed */
.input-group:has(input:not(:placeholder-shown):invalid) .error-message {
opacity: 1;
transform: translateY(0);
}
8. Performance Considerations
While CSS-only solutions are generally performant, there are pitfalls to avoid.
1. Avoid Layout Thrashing in Selectors
Complex selector chains (e.g., div > ul > li > a:hover span) can be slower because the browser must traverse the DOM upwards. However, with modern engines like Blink and WebKit, this is rarely a bottleneck unless you are selecting millions of elements.
2. :has() and Performance
:has() can be expensive if used indiscriminately. For example, body:has(.modal-open) requires the browser to check the entire document tree for .modal-open every time the body is rendered. If you have many :has() selectors, keep them as specific as possible.
Bad:
*:has(> .active) {
background: red;
}
Good:
.nav-item:has(> .active-link) {
background: red;
}
3. Hardware Acceleration
Use transform and opacity for animations. These properties are handled by the GPU and do not trigger reflows. Avoid animating width, height, top, or left, which force the browser to recalculate layout.
9. Frequently Asked Questions
Q: Can I use :has() to select a previous sibling?
A: No, :has() selects children (or descendants). It cannot select previous siblings. For previous sibling styling, you must rely on the order of elements in the DOM and use sibling combinators (+ or ~) on the current element, or use the "Radio Button Hack" pattern if you need complex state management.
Q: How do I handle fallbacks for older browsers?
A: Use progressive enhancement. Build the base functionality with standard HTML and CSS that works without :has(). Then, use @supports to layer in advanced features.
/* Base styles work everywhere */
.accordion-content {
display: none;
}
.accordion-item[open] .accordion-content {
display: block;
}
/* Enhanced styles for modern browsers */
@supports (selector(:has(*))) {
.accordion-content {
display: block; /* Reset display for grid animation */
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.accordion-item:has([open]) .accordion-content {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
}
Q: Is CSS-only interactivity suitable for production apps?
A: Yes, for UI components like accordions, dropdowns, modals, and tabs. For complex application logic (e.g., data fetching, complex form validation logic, global state management), JavaScript is still required. Use CSS for presentation and interaction, and JavaScript for data and logic.
By leveraging these advanced CSS selectors, you can create highly interactive, accessible, and performant user interfaces without the overhead of JavaScript. For more insights on modern frontend architecture, check out Tamiz's Insights.
Conclusion
The shift towards CSS-only interactivity is not just a trend; it is a maturation of the web platform. By mastering :has(), :is(), and :where(), you gain the ability to build robust, state-driven UIs that are easier to maintain, more performant, and inherently accessible. Start small—replace a simple hover effect or a basic accordion with a CSS-only solution—and gradually expand your toolkit. The future of frontend development is declarative, and CSS is leading the way.
Top comments (0)