Building a Simple Tabs Component Where JavaScript Serves the CSS
Build a tabs component where JavaScript only toggles data-state attributes and ARIA, leaving the visual logic to CSS cascade, scroll-driven animations, and container queries.
Your tabs component does not need a framework, a state manager, or an IntersectionObserver. The minimum JavaScript is one line inside a click handler: toggle a data-state value on the tablist, then mirror that state to the aria-selected attributes on the tabs and the hidden state of the panels. Everything that makes the tabs look active, fade, slide, or scroll into view is CSS. The JavaScript serves the CSS, not the other way around. Here are three complete, runnable samples, each with its own failure case, and the older technique each one replaces. Start with the CSS-only approach, because it taught the browsers what tabs need, then move to the scroll-driven animation, then to the production component you would actually ship.
CSS-Only Tabs With :target
The `:target` pseudo-class is the oldest trick that works without a single line of script. Put an `id` on each tab panel, make each tab an anchor pointing at that `id`, and use `:target` to show the panel whose `id` matches the URL fragment. The non-matching panels stay hidden with `display: none`. The technique works. It is still the right answer for a page where tabs are a progressive enhancement. It has two hard limitations you need to name before you build on it.
Here is the full sample:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSS-only tabs with :target</title>
<style>
.tabs { display: flex; gap: 0.5rem; }
.tab-panel {
display: none;
padding: 1rem;
border: 1px solid #ccc;
}
.tab-panel:target {
display: block;
}
/* Default visible panel: the first one when no fragment matches */
.tab-panel:not(:target) ~ .tab-panel:first-of-type {
display: block;
}
</style>
</head>
<body>
<div class="tabs" role="tablist" aria-label="Sample tabs">
<a href="#tab1" role="tab" aria-selected="true">Alpha</a>
<a href="#tab2" role="tab" aria-selected="false">Beta</a>
<a href="#tab3" role="tab" aria-selected="false">Gamma</a>
</div>
<div id="tab1" class="tab-panel" role="tabpanel" aria-labelledby="tab1">First panel content.</div>
<div id="tab2" class="tab-panel" role="tabpanel" aria-labelledby="tab2">Second panel content.</div>
<div id="tab3" class="tab-panel" role="tabpanel" aria-labelledby="tab3">Third panel content.</div>
</body>
</html>
The first limitation is focus. Arrow-key navigation between tabs requires JavaScript, because the browser does not move focus between anchors on its own. The second is history. Every tab activation pushes a new entry into the browser history, so the back button walks through every tab you opened. That is annoying on a long page and it is a real accessibility problem for users who navigate with the keyboard and the back button both.
The `:target` approach replaces the JavaScript-driven tab switching that used `onclick` event handlers and class toggling. Use it when you can live with the history pollution and you do not need arrow keys. If you need those, the approach below keeps the CSS-only feel but adds the minimal script that the accessibility tree demands.
Scroll-Driven Tabs With The view() Timeline
The `view()` timeline is the modern replacement for IntersectionObserver. Instead of watching which panel is in the viewport and toggling a class from JavaScript, you declare an animation that progresses as the panel scrolls through the scrollport. The animation sets the active tab's background, and the browser runs it on the compositor thread. It never touches the main thread and it never janks.
Here is the full sample:
/* Scroll-driven tabs: the panel's progress through the viewport drives the tab highlight */
@supports (animation-timeline: view()) {
.tab-strip {
display: flex;
gap: 0.5rem;
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
.tab-strip a {
padding: 0.5rem 1rem;
text-decoration: none;
color: #333;
border-bottom: 3px solid transparent;
}
.tab-panel-scroll {
height: 60vh;
overflow-y: auto;
}
.panel {
min-height: 100%;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.tab-strip a {
animation: tab-highlight linear both;
animation-timeline: view();
animation-range: entry 0% exit 100%;
}
@keyframes tab-highlight {
0%, 49% { border-bottom-color: transparent; }
50%, 100% { border-bottom-color: #06c; }
}
}
<div class="tab-strip" role="tablist" aria-label="Scroll tabs">
<a href="#scroll-panel-1" role="tab" aria-selected="true">One</a>
<a href="#scroll-panel-2" role="tab" aria-selected="false">Two</a>
<a href="#scroll-panel-3" role="tab" aria-selected="false">Three</a>
</div>
<div class="tab-panel-scroll">
<section id="scroll-panel-1" class="panel" role="tabpanel" aria-labelledby="scroll-panel-1">First panel.</section>
<section id="scroll-panel-2" class="panel" role="tabpanel" aria-labelledby="scroll-panel-2">Second panel.</section>
<section id="scroll-panel-3" class="panel" role="tabpanel" aria-labelledby="scroll-panel-3">Third panel.</section>
</div>
This technique replaces the all-JavaScript scroll spy, the kind that added an active class to a nav link every time the scroll position crossed a threshold. That old approach ran on the main thread, needed a `requestAnimationFrame` loop or a throttled scroll listener, and could miss the active state entirely when the user scrolled fast. The `view()` timeline hands the same job to the browser's compositor, which does it once per frame and never blocks the main thread.
The failure case is support. The `@supports (animation-timeline: view())` guard is mandatory, and when it fails you need a fallback. The fallback is the same component with the `data-state` approach below, or a simple IntersectionObserver if you must keep the scroll behaviour. Never ship scroll-driven tabs without the `@supports` guard. An unsupported `animation-timeline` value silently does nothing, and your tabs will look like a flat list of links with no active state at all.
Production-Ready Tabs Component
The `data-state` value is the contract between JavaScript and CSS. The JavaScript toggles the value on the tablist, and the CSS reads that value to style the active tab and hide the inactive panels. It is not a class, so it cannot collide with a framework's class names. It carries state in a way that is inspectable in the devtools and testable in a unit test.
Here is the full sample:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Data-state tabs</title>
<style>
.tabs-prod {
display: flex;
gap: 0.5rem;
}
.tab-prod {
padding: 0.5rem 1rem;
border: 1px solid #ccc;
background: #f5f5f5;
cursor: pointer;
}
.tab-panel-prod {
display: none;
padding: 1rem;
border: 1px solid #ccc;
}
.tabs-prod[data-state="tab-1"] .tab-panel-prod[data-panel="1"],
.tabs-prod[data-state="tab-2"] .tab-panel-prod[data-panel="2"],
.tabs-prod[data-state="tab-3"] .tab-panel-prod[data-panel="3"] {
display: block;
}
.tabs-prod[data-state="tab-1"] .tab-prod[data-tab="1"],
.tabs-prod[data-state="tab-2"] .tab-prod[data-tab="2"],
.tabs-prod[data-state="tab-3"] .tab-prod[data-tab="3"] {
background: #06c;
color: #fff;
border-color: #06c;
}
@media (forced-colors: active) {
.tab-prod[aria-selected="true"] {
outline: 2px solid Highlight;
}
}
@media (prefers-reduced-motion: reduce) {
.tab-panel-prod {
transition: none;
}
}
</style>
</head>
<body>
<div class="tabs-prod" role="tablist" aria-label="Production tabs" data-state="tab-1">
<button class="tab-prod" role="tab" aria-selected="true" aria-controls="prod-panel-1" data-tab="1">One</button>
<button class="tab-prod" role="tab" aria-selected="false" aria-controls="prod-panel-2" data-tab="2">Two</button>
<button class="tab-prod" role="tab" aria-selected="false" aria-controls="prod-panel-3" data-tab="3">Three</button>
</div>
<div class="tab-panel-prod" id="prod-panel-1" role="tabpanel" aria-labelledby="prod-tab-1" data-panel="1">First panel.</div>
<div class="tab-panel-prod" id="prod-panel-2" role="tabpanel" aria-labelledby="prod-tab-2" data-panel="2">Second panel.</div>
<div class="tab-panel-prod" id="prod-panel-3" role="tabpanel" aria-labelledby="prod-tab-3" data-panel="3">Third panel.</div>
<script>
const tablist = document.querySelector('.tabs-prod');
const tabs = tablist.querySelectorAll('.tab-prod');
const panels = document.querySelectorAll('.tab-panel-prod');
tabs.forEach((tab, index) => {
tab.addEventListener('click', () => {
tablist.dataset.state = 'tab-' + (index + 1);
tabs.forEach(t => t.setAttribute('aria-selected', t === tab ? 'true' : 'false'));
panels.forEach(p => {
p.hidden = p.id !== 'prod-panel-' + (index + 1);
});
});
});
</script>
</body>
</html>
That script is the absolute minimum. It updates the `data-state` value, which the CSS reads to style the active tab and show the right panel. It sets `aria-selected` to tell the accessibility tree which tab is active, and it toggles the `hidden` property on the panels, which removes them from the accessibility tree so screen readers do not announce inactive panels. The CSS never needs to know which panel is active except through the `data-state` value, and the JavaScript never needs to know a single style rule.
This component replaces the jQuery UI tabs widget, which carried a plugin's worth of JavaScript, a theme stylesheet, and a dozen classes for states like `ui-state-active` and `ui-state-hover`. It also replaces the React state-managed tab, where every click dispatches an action, runs a reducer, and re-renders the virtual DOM. Here the state lives in the DOM, the CSS reads it directly, and there is no component tree to reconcile.
The failure case is focus management. The click handler toggles state, but it does not move focus. In the WAI-ARIA tabs pattern, arrow keys move focus between tabs, and the active tab takes focus when the tablist receives it. That requires a `keydown` listener, and it is the one piece of JavaScript this component cannot avoid. If you skip it, keyboard users can still click each tab, but they cannot navigate with the arrow keys, and that is a WCAG failure under 2.1.1 Keyboard. Add the `keydown` handler, and the component is complete.
Progressive Enhancement Tabs
The `data-state` approach is the base layer, and the `:target` approach is the no-JavaScript fallback. Serve the `:target` version to browsers without script support. When JavaScript loads, add the `data-state` value and the `keydown` handler. The `@supports selector()` guard for `:target` keeps the fallback from leaking into browsers that do not support it.
Here is the full sample:
/* Base: the :target pattern as a no-JS fallback */
.tab-panel-enh {
display: none;
}
.tab-panel-enh:target {
display: block;
}
.tab-panel-enh:not(:target) ~ .tab-panel-enh:first-of-type {
display: block;
}
/* Enhancement: data-state takes over when JavaScript runs */
.js-enabled .tabs-enh {
display: flex;
}
.js-enabled .tab-panel-enh {
display: none;
}
.js-enabled .tabs-enh[data-state="tab-1"] .tab-panel-enh[data-panel="1"],
.js-enabled .tabs-enh[data-state="tab-2"] .tab-panel-enh[data-panel="2"],
.js-enabled .tabs-enh[data-state="tab-3"] .tab-panel-enh[data-panel="3"] {
display: block;
}
<div class="tabs-enh" role="tablist" aria-label="Enhanced tabs" data-state="tab-1">
<a href="#enh-panel-1" class="tab-enh" role="tab" aria-selected="true" data-tab="1">One</a>
<a href="#enh-panel-2" class="tab-enh" role="tab" aria-selected="false" data-tab="2">Two</a>
<a href="#enh-panel-3" class="tab-enh" role="tab" aria-selected="false" data-tab="3">Three</a>
</div>
<div id="enh-panel-1" class="tab-panel-enh" role="tabpanel" aria-labelledby="enh-tab-1" data-panel="1">First panel.</div>
<div id="enh-panel-2" class="tab-panel-enh" role="tabpanel" aria-labelledby="enh-tab-2" data-panel="2">Second panel.</div>
<div id="enh-panel-3" class="tab-panel-enh" role="tabpanel" aria-labelledby="enh-tab-3" data-panel="3">Third panel.</div>
<script>
document.documentElement.classList.add('js-enabled');
const tablistEnh = document.querySelector('.tabs-enh');
const tabsEnh = tablistEnh.querySelectorAll('.tab-enh');
const panelsEnh = document.querySelectorAll('.tab-panel-enh');
tabsEnh.forEach((tab, index) => {
tab.addEventListener('click', (e) => {
e.preventDefault();
tablistEnh.dataset.state = 'tab-' + (index + 1);
tabsEnh.forEach(t => t.setAttribute('aria-selected', t === tab ? 'true' : 'false'));
panelsEnh.forEach(p => {
p.hidden = p.id !== 'enh-panel-' + (index + 1);
});
});
});
</script>
This approach replaces the all-JavaScript tab system that leaves no content visible when the script fails or is blocked. The fallback is not a blank page; it is the `:target` version, which works in every browser that supports fragment navigation. The enhancement layer adds the `data-state` value, the ARIA updates, and the focus management, so the component is accessible and functional in the modern browser while remaining usable in the oldest one.
The failure case is the moment the script loads but the `@supports selector()` check fails. In that case, the `:target` rules apply, the `.js-enabled` rules do not exist, and the component works as a pure anchor navigation. That is not a bug; it is the progressive enhancement doing its job. The script adds the class before the click handler is attached, so there is no flash of unstyled content between the `:target` rules and the `data-state` rules.
Limitations Of The :target Approach
The `:target` approach has a specific set of failure modes. The first is the history accumulation, which we covered. The second is the default panel logic. The selector `.tab-panel:not(:target) ~ .tab-panel:first-of-type` is fragile because it depends on the first panel being the one that shows when no fragment matches. If the URL has a fragment for a different panel, the first panel hides and the targeted one shows, which is correct. But if the fragment is for a non-existent `id`, every panel hides and the page is blank, which is a failure.
Another failure is the cascade layer interaction. If you put the `:target` rules inside a `@layer`, the unlayered author styles will override them regardless of specificity, because unlayered styles beat layered styles. That is a common mistake when adding a tab technique to an existing design system that already uses `@layer`. The fix is to put the tab styles in the same layer as the rest of the component, or to leave them unlayered.
The third failure is the `animation-fill-mode` interaction. If you animate the tab panels with a CSS transition, the `:target` rule changing `display` from `none` to `block` does not transition, because `display` is not an animatable property. The panel appears instantly, and the transition on `opacity` or `transform` does not run. To get a fade, animate the `opacity` and the `transform`, not the `display`, and keep the panel in the layout with a `hidden` property or a `visibility` toggle, not `display: none`. The `data-state` approach avoids this because the CSS can apply a transition on the `opacity` of the active panel without changing its `display`.
The fourth failure is the `@supports selector()` guard. The `:target` pseudo-class is supported in every browser that has ever shipped it, so the guard is almost always true. But the guard is still useful when you layer `:target` with `popover`, because the `popover` property has a different support timeline. Use `@supports (selector([popover]:open))` to guard the popover-based tabs, not the `:target`-based ones.
Limitations Of The Scroll-Driven Approach
The `view()` timeline is not a drop-in replacement for every scroll spy. The `animation-range` property controls when the animation starts and stops, and getting it wrong produces a tab that highlights too early or too late. The range `entry 0% exit 100%` means the animation starts when the panel enters the scrollport and ends when it exits, which is the correct behaviour for a sticky tab strip. But if you use a vertical scroll container with a horizontal tab strip, the `view()` timeline tracks the vertical progress, not the horizontal, so the animation does not match the scroll direction.
The second limitation is the compositor thread. The animation runs on the compositor only if the animated properties are `transform` and `opacity`. If you animate `border-bottom-color`, as in the sample above, the browser may promote the animation to the main thread, and the performance benefit disappears. The safe choice is to animate `transform` and `opacity` only, and to change the color with a keyframe that flips at the midpoint, which the sample does. The `border-bottom-color` is not compositor-safe, so for a production scroll-driven tab, animate a pseudo-element's `transform` instead.
The third limitation is the fallback. The `@supports` guard is mandatory, but the fallback is not automatic. Write the fallback rules inside the `@supports` block or outside it, and test both paths. The `data-state` component is the reliable fallback, because it does not depend on scroll position at all. The user scrolls, and the JavaScript updates the state, which the CSS reads. That is the same behaviour the scroll-driven animation provides, but without the compositor benefit.
How The data-state Hook Works
The `data-state` value is a custom data attribute, not a state pseudo-class like `:checked` or `:target`. It is a plain string on the element, and the CSS attribute selector reads it. The name is arbitrary, but `data-state` is a convention that survives across design systems. The value is the active tab's identifier, and the CSS uses that value to select the panel and the tab.
The approach is declarative: the CSS declares the entire visual state space, one rule per possible `data-state` value. Three tabs means three rules. Ten tabs means ten rules, and that is the limitation. The CSS does not loop, so the number of rules grows linearly with the number of tabs. For a component with a fixed, small number of tabs, this is concise and readable. For a dynamic list, use a class toggle on the active tab and a sibling selector on the panel. The CSS can then use a single rule like `.tab-panel.active { display: block; }`.
The `data-state` hook also works with the accessibility tree in a way that classes do not. The `aria-selected` property is set by JavaScript, and the CSS does not read it. The CSS reads the `data-state` value, and the JavaScript sets both. That separation means the CSS can change without touching the JavaScript, and the JavaScript can change without touching the CSS. The `classList.toggle` method is the JavaScript API that most developers reach for, but for a `data-state` hook you use the `dataset` property, which is a direct mapping from `data-*` attributes to camelCase properties. The `dataset.state` setter writes `data-state`, and the CSS selector `[data-state="tab-1"]` reads it.
This technique replaces the class-toggle pattern where the class name carries both the visual state and the accessibility state. With a class, you might write `.tab.active { … }` and rely on the same class for `aria-selected`, which is a coupling. The `data-state` hook decouples them, so the accessibility tree is not dependent on the CSS class naming.
FAQ
Can CSS-only tabs be keyboard accessible without JavaScript?
No. The `:target` technique responds to clicks and to the browser's back button, but it does not move focus between tabs with arrow keys. The WAI-ARIA tabs pattern requires arrow-key navigation, and that requires a `keydown` event listener. The CSS-only approach is functional without JavaScript for mouse users and for screen reader users who activate links, but it fails the keyboard navigation requirement under WCAG 2.1.1.
When should I use the scroll-driven tabs animation instead of the data-state component?
Use the scroll-driven animation when the tabs are the primary navigation and the panels are long sections that the user scrolls through naturally. The animation provides a visual highlight without any JavaScript, and it runs on the compositor thread. Use the `data-state` component when the tabs are a compact widget below a form or in a modal, where the user clicks and expects immediate panel switching without scrolling.
What is the fallback when the data-state script fails to load?
The progressive enhancement pattern is the fallback. The `:target` rules work without JavaScript, so the panels are still reachable via anchors. The `data-state` rules only apply when the `.js-enabled` class is present, and the script adds that class before attaching the click handlers. If the script fails, the class is not added, the `:target` rules apply, and the component degrades to the anchor pattern.
Does the data-state value affect the accessibility tree directly?
No. The `data-state` value is not read by screen readers. The accessibility tree is affected by the `role`, `aria-selected`, and `hidden` attributes. The JavaScript sets `aria-selected` on the tabs and toggles `hidden` on the panels. The `data-state` value is only a CSS hook. If you forget to set `aria-selected`, the component looks correct visually but announces every tab as selected, which is a failure.
Can I animate the panel transition with the data-state component?
Yes, but not by animating `display`. The `display` property is not animatable, so the panel appears instantly. To get a fade, keep the panel in the layout with the `hidden` property and animate `opacity` and `transform`. The `hidden` property is not the same as `display: none` for CSS purposes: you can set `.tab-panel[hidden] { display: none; }` to remove it from the layout, but you cannot transition to that state. Instead, use the `hidden` property on the inactive panels and animate the active panel's `opacity` and `transform`.
Fixing The Border-Bottom-Color Jank
The scroll-driven tabs animation sample animates `border-bottom-color`, which is not compositor-safe. The practical fix is to animate a pseudo-element's `transform` instead. The compositor thread only handles `transform` and `opacity`, and the `border-bottom-color` animation will silently move to the main thread and jank on long pages.