Door 12 – State Styling without JavaScript | CSS Adventskalender
Skip to content

Door 12 – State Styling without JavaScript

Published: at 07:00 AM

Interactivity has long been firmly in the hands of JavaScript. For many UI states such as dropdowns, accordions, active navigation elements, validations, the rule was: It doesn’t work without JavaScript.

But modern CSS selectors like :checked, :focus-within, :target, and especially :has() have changed the playing field. They enable state logic directly in CSS, without additional scripts. This makes components simpler, more robust, and often even more performant.

This door shows how to implement state-based logic purely with CSS and where the limits and opportunities lie.

The Key: Selectors That React to Interaction

The most important tools for state-based CSS are modern pseudo-classes and attribute selectors that react to user interactions and browser states. The :checked selector allows you to react to activated checkboxes and radio buttons, making it the foundation for many toggle mechanisms. :focus-within goes a step further than simple :focus and reacts not only when an element itself has focus, but also when one of its child elements is focused. This is ideal for dropdown menus and nested navigations.

The :target selector addresses elements that are accessed via a hash in the URL, enabling URL-based navigation without JavaScript. The relatively new :has() selector is particularly powerful as it allows you to style an element based on the presence of certain child elements, such as a “parent selector” that developers have been asking for for years. And finally, the native <details> element with its [open] attribute offers an HTML-native solution for collapsible areas that can be styled excellently with CSS.

Each of these selectors represents a form of “state” that can be used directly in CSS without relying on JavaScript event handlers or state management. This makes components not only simpler but also more robust and in many cases more accessible.

Pattern 1: Accordion Without JavaScript

A classic that works seamlessly without JS today.

HTML

<div class="accordion">
  <input type="checkbox" id="acc-1" hidden>
  <label for="acc-1" class="accordion-header">Title</label>
  <div class="accordion-panel">Content …</div>
</div>

CSS

.accordion-panel {
  display: none;
}

input:checked + .accordion-header + .accordion-panel {
  display: block;
}

The pattern uses the browser’s natural checkbox functionality and connects it with the <label> element, which acts as a visual trigger. Using the adjacent sibling selector (+), the panel is only visible when the hidden checkbox is activated. The crucial advantage of this solution is that it works completely without JavaScript and yet remains robust.

Accessibility here depends heavily on the concrete implementation of the label. If the label is semantically correctly linked to the checkbox and uses clear descriptions, the pattern also works flawlessly with screen readers. No additional work is needed for keyboard navigation, as the native checkbox functionality is already fully accessible.

In modern projects, this pattern remains one of the simplest and most maintainable solutions for collapsible areas. It works in all browsers, requires no build tools, and can be easily integrated into any design.

Pattern 2: Dropdowns with :focus-within

:focus-within reacts when an element or one of its children has focus.

HTML

<div class="dropdown">
  <button class="trigger">Menu</button>
  <ul class="menu">
    <li><a href="#item1">Item 1</a></li>
    <li><a href="#item2">Item 2</a></li>
  </ul>
</div>

CSS

.menu {
  opacity: 0;
  pointer-events: none;
  transform: translateY(-4px);
  transition: 120ms;
}

.dropdown:focus-within .menu {
  opacity: 1;
  pointer-events: auto;
  transform: translateY(0);
}

The dropdown opens automatically as soon as the button or one of the menu items receives focus. The crucial advantage of :focus-within is that the menu stays open as long as focus moves within the dropdown container, such as when the user navigates through the menu items with the tab key. Only when focus leaves the container does the menu close again.

This solution works completely with the keyboard and thus fulfills basic accessibility requirements. Users without a mouse can open the menu via tab navigation and navigate through the options. The pointer-events property ensures that the invisible menu doesn’t block mouse interactions when it’s closed.

An important note: For production-ready dropdowns, the Escape key should additionally be implemented for closing, which does require a small JavaScript snippet. However, for simple hover and focus menus, the pure CSS solution is completely sufficient and significantly more maintainable than a full JavaScript implementation.

Pattern 3: Tabs with :target

:target reacts when an element is addressed via the hash in the URL:

.tab-panel {
  display: none;
}

.tab-panel:target {
  display: block;
}

HTML

<nav class="tabs">
  <a href="#tab1">Tab 1</a>
  <a href="#tab2">Tab 2</a>
</nav>

<section id="tab1" class="tab-panel">Content 1</section>
<section id="tab2" class="tab-panel">Content 2</section>

Note: With this solution, no tab is initially visible until a hash is active in the URL. In practice, you often set a default hash (e.g., page.html#tab1) or use :has() on a shared container to define a fallback: .tab-container:not(:has(.tab-panel:target)) .tab-panel:first-of-type { display: block; } – this displays the first tab as long as no :target is active. For this, tabs and panels must be within a common container.

The strength of the :target selector lies in its close connection to the browser URL. When a user clicks on a link with a hash fragment (e.g., #tab2), the corresponding element becomes the “target” and can be specifically styled via CSS. This makes this solution particularly bookmark-friendly. The active tab becomes part of the URL and can be directly linked or saved.

This property is especially valuable for documentation-style pages, tutorial content, or FAQ areas where users often want to jump directly to a specific section. The solution works without any JavaScript and is thus extremely robust. Browser history also works out-of-the-box. Navigating forward and backward between tabs works as usual.

An additional advantage is accessibility. Screen readers can easily follow navigation via hash links, and the browser’s native “jump to anchor” functionality is preserved. For simple tab systems where no complex state management is needed, :target remains an elegant and maintainable solution.

Pattern 4: Validations via :has()

:has() is the most powerful of the modern selectors – especially with forms.

Example: Error States in Form

.form-group:has(input:invalid) {
  border-color: var(--error);
  background: var(--error-bg);
}

The :has() selector enables styling a parent element based on the state of its children. In this example, the entire .form-group is given error colors as soon as it contains an invalid input element. This is particularly elegant because no additional CSS classes need to be set via JavaScript.

The browser’s native form validation is used directly here. Pseudo-classes like :invalid, :valid, :required, or :in-range have existed for a long time, but previously could only be applied to the input elements themselves. With :has(), these states can now be transferred to the entire layout without JavaScript intervention.

This results in significantly less code, less error-prone logic, and a tighter coupling between HTML validation and visual feedback. Especially in forms with many fields, this approach considerably simplifies state management.

Note: :has() is a relatively new feature (Chrome 105+, Safari 15.4+, Firefox 121+). For projects supporting older browsers, fallback styling or a progressive enhancement strategy should be planned.

Pattern 5: Cards That Orient Themselves to Child Elements

With :has(), components can be styled context-dependently.

Example: Card with Image

.card {
  display: grid;
  gap: 1rem;
}

.card:has(img) {
  grid-template-columns: 150px 1fr;
  align-items: center;
}

With this, the card automatically becomes two-column if an image is contained and that completely independent of HTML or framework. The card adapts its layout automatically to its content – without needing additional modifier classes or props.

Browser Support: :has() is available in modern browsers (Chrome 105+, Safari 15.4+, Firefox 121+). For older browsers, a fallback layout without :has() can be defined that simply remains single-column.

Pattern 6: Accordion Without Hidden Inputs – Thanks to

The native <details> element is an underestimated HTML feature.

<details class="accordion">
  <summary>Headline</summary>
  <p>Content …</p>
</details>

CSS:

.accordion[open] {
  border-color: var(--accent);
}

The <details> element is a frequently underestimated HTML feature that enables collapsible areas out of the box and that without any CSS or JavaScript. The <summary> element acts as a clickable trigger, and the remaining content is only displayed when the element is open. The browser takes care of all interaction logic, including keyboard navigation, touch gestures, and screen reader support.

The [open] attribute allows the open state to be addressed in CSS and styled individually. This is particularly elegant because the native interaction is fully preserved and only the visual appearance is customized. Accessibility with correct use of <details> and <summary> is excellent, as both elements are semantically clearly defined and correctly interpreted by assistive technologies.

For simple accordions, FAQ areas, or collapsible additional information, <details> is often the cleanest solution. The implementation is minimal, browser support is excellent and the behavior works even without CSS or JavaScript. More complex requirements like animated transitions do require additional CSS or JS code, but the basic functionality is already fully present.

Pattern 7: Toggle Without JS via :checked

A simple pattern for mobile navigations:

<input type="checkbox" id="nav-toggle" hidden>
<label for="nav-toggle" aria-label="Open/close menu"></label>

<nav class="mobile-nav">

</nav>

CSS:

.mobile-nav {
  transform: translateX(-100%);
  transition: 200ms;
}

#nav-toggle:checked ~ .mobile-nav {
  transform: translateX(0);
}

Again: No JavaScript necessary.

Accessibility Note: The label should contain a meaningful aria-label or visible text so screen reader users understand the function. The icon (☰) alone is not sufficiently accessible. Alternatively, a visually hidden text can be used inside the label.

Combination with Transitions

Many of these patterns only become truly elegant through animations.

Example:

.panel {
  max-height: 0;
  overflow: hidden;
  transition: max-height 200ms ease;
}

input:checked ~ .panel {
  max-height: 500px;
}

This pattern creates fluid accordion movements and that completely without JavaScript.

Limits of CSS State-Logic

As powerful as modern CSS selectors are, there are clear boundaries where JavaScript remains necessary. Complex state logic with multiple conditions (e.g., “if A or B is active, but not C”) can hardly be represented readably with CSS selectors. Even though :has() enables a lot, complexity quickly becomes confusing with nested conditions.

Dynamic data loaded from APIs or changed through user interactions also requires JavaScript. CSS can only react to elements and their states already present in the DOM, but cannot generate new content or process external data. Likewise, asynchronous interactions such as lazy loading of content, timeouts, or event-based communication between components are outside the CSS domain.

Advanced UI features like drag & drop, multi-select with complex logic, or context-dependent menus with dynamic content cannot be implemented with pure CSS. Complex animations based on physical simulations or that interact with user gestures also require JavaScript.

Nevertheless, CSS already cleanly covers a surprisingly large number of standard interactions. For accordions, dropdowns, tabs, simple validations, and context-dependent styling, JavaScript is often no longer necessary. This leads to more maintainable, more performant, and more robust components, as long as you know and accept the limits.

Conclusion

Modern CSS selectors have significantly shifted the boundary between CSS and JavaScript. Interactions that previously strictly required JavaScript can today often be represented directly in the stylesheet. This leads to components that improve in several ways.

They become simpler because the state logic lives directly in CSS and isn’t distributed across event handlers and state management in JavaScript. They become more robust because they build on browser-native functions that are already extensively tested and work consistently in all modern browsers. Accessibility often improves automatically because HTML-native elements like <details> or checkbox functionality are already fully accessible and correctly interpreted by assistive technologies.

Components also become easier to reuse because they don’t bring JavaScript dependencies and can be easily integrated into different projects and frameworks. Context-dependency increases through selectors like :has(), which enable components to automatically adapt based on their content or environment and that without needing additional classes or props.

This development doesn’t mean JavaScript becomes obsolete. For complex applications, it remains indispensable. But for many standard interactions, CSS is today the simpler, more maintainable, and often even more performant choice.


☕ Buy me a coffee

If you enjoy my articles and they help you in your work, I would appreciate a "coffee" and a few kind words from you.

Buy me a coffee

Previous Post
Door 13 – CSS Architectures. BEM, ITCSS, CUBE CSS Compared
Next Post
Door 11 – CSS Custom Properties

Comments