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.