The CSS :target Pseudo-Class, Explained
CSS :target styles the element whose ID matches the URL fragment, enabling tabs, modals, and highlights with zero JavaScript. How it works, with examples.
The :target pseudo-class matches an element whose id matches the fragment identifier in the current URL — the part after the #. When a visitor follows a link to /page#section-2, the element with id="section-2" becomes the target, and any CSS rule using :target applies to it, and only it, until the fragment changes again.
It’s one of the oldest CSS selectors that reacts to browser state rather than document structure, predating things like :focus-visible or :has(). Because it’s driven purely by the URL fragment, it needs no JavaScript, no event listeners, and no state management — the browser’s own navigation does the work.
The basic mechanism
.panel {
display: none;
}
.panel:target {
display: block;
}
<a href="#panel-a">Show panel A</a>
<a href="#panel-b">Show panel B</a>
<div id="panel-a" class="panel">Content A</div>
<div id="panel-b" class="panel">Content B</div>
Clicking either link changes location.hash, the browser matches the corresponding id, and :target applies. No panel is shown until a link is clicked, and only the matching one appears afterward. Because the state lives in the URL, the result is bookmarkable, shareable, and works with the browser’s back button for free — a real advantage over JavaScript-driven visibility toggles that don’t touch the address bar.
Practical uses
Highlighting a linked section. Documentation sites often want to draw attention to whichever heading a reader jumped to via an anchor link:
:target {
scroll-margin-top: 2rem;
background: light-dark(#fff3cd, #4a3b0a);
}
Pairing this with CSS scroll-snap or smooth scrolling and a scroll-margin-top avoids the classic problem where a fixed header covers the top of the section you just navigated to.
CSS-only tabs and accordions. The panel example above generalizes into a full tab interface: one link per tab, one panel per tab, and :target decides which panel is visible. It’s a common technique for a lightweight component where a full JavaScript tab library would be overkill, though it has real limitations — covered below.
CSS-only modals. A common pattern wraps a modal in a fixed-position overlay that’s hidden by default and shown with :target, with a close link pointing back to a URL without the fragment (often #! or #_) to dismiss it.
.modal-overlay {
display: none;
position: fixed;
inset: 0;
}
.modal-overlay:target {
display: flex;
}
This works without JavaScript, though the <dialog> element and the newer Popover API are purpose-built for modals and handle focus trapping and accessibility semantics that a :target modal has to be built by hand.
The limitations
:target only tracks one thing at a time: the single element whose id matches the current fragment. You can’t have two independent :target-driven states on the same page — showing one tab doesn’t let you also track a separate modal’s open state, because both would compete for the same URL fragment.
It also changes the browsing history: every click on a :target-triggering link is a real navigation, which means the back button steps through every tab or panel change one at a time. For a tab interface that’s often undesirable — users expect the back button to leave the page, not cycle through the last few tabs they clicked. There’s no direct fix within CSS for this; it’s an inherent trade-off of hash-based state.
Finally, :target provides no way to set an initial active state without a matching fragment already in the URL. A tab interface built this way typically needs one panel with a fallback display: block and :target ~ .panel { display: none }-style overrides, or a small amount of JavaScript to set a default location.hash on load.
:target vs data attributes and JavaScript state
:target | JS-driven state (class/data attribute) | |
|---|---|---|
| Requires JavaScript | No | Yes |
| Bookmarkable / shareable | Yes, automatically | Only if you sync the URL yourself |
| Multiple independent states | No — one fragment at a time | Yes |
| Back-button behavior | Every change is a history entry | Configurable |
| Initial state without navigation | Not directly | Trivial |
:target is the right tool for a small number of genuinely link-driven states — a highlighted section, a single dismissible banner, a simple set of tabs where the URL should reflect the current view. For anything with multiple independent toggles, or where you don’t want every interaction to push a history entry, JavaScript-managed state (or the Popover API for the disclosure cases it covers) is the better fit.
Combining it with other modern selectors
:target composes with the rest of the selector language the same way any pseudo-class does. :target::before styles a generated marker only on the active element; :not(:target) selects everything except the current target, useful for dimming inactive panels rather than hiding them outright. It also works inside :has() to style a parent based on whether a descendant is currently targeted — article:has(:target) { border-color: var(--accent); } — without any script watching for hash changes.
The takeaway
:target matches the element whose id equals the current URL fragment, giving CSS a way to react to navigation without JavaScript. It’s a solid fit for link-highlighted sections, single-state modals, and simple tab UIs, but it can only track one active element at a time and turns every state change into a browser history entry — know those two limits before reaching for it over a small script or the Popover API.
Tagged
Keep reading
Takina · · 4 min read Container Query Units: cqw, cqh, and cqi Explained
Container query units like cqw and cqi size elements relative to their containing element, not the viewport — here's how and when to use them.
Takina · · 4 min read CSS Relative Color Syntax: Deriving Colors from Colors
Relative color syntax lets CSS derive a new color from an existing one by adjusting its channels directly, no preprocessor or JS required.
Takina · · 4 min read What Is CSS Subgrid? Nested Grid Alignment Explained
CSS subgrid lets a nested grid item inherit its parent's track sizing, so children can align to the same columns or rows across unrelated containers.