Build Pure CSS Tabs Using the :target Pseudo-Class

The CSS :target pseudo-class allows developers to create functional, interactive tabbed interfaces entirely without JavaScript by utilizing URL fragment identifiers. When a user clicks an anchor link containing a specific hash, the browser matches that fragment to an element with an identical id attribute, triggering the :target state. This article explains the technical mechanics of the :target pseudo-class, how to structure and style a CSS-only tab system, and the advantages and trade-offs of this approach.

The Core Mechanism of the :target Pseudo-Class

Every URL can include a fragment identifier—a hash symbol (#) followed by an anchor name. When the browser navigates to a URL containing a fragment, such as example.com/#tab2, the document identifies any element with id="tab2" as the target element.

CSS provides the :target pseudo-class to match and apply styles exclusively to this targeted element. In a tabbed interface, this dynamic state replaces the need for JavaScript event listeners and class toggling (element.classList.add('active')).

Structuring the HTML

To build a pure CSS tabbed interface, you need navigation links that reference specific identifiers, along with content containers marked with matching id attributes:

<div class="tabs-container">
  <!-- Tab Navigation -->
  <nav class="tab-nav">
    <a href="#tab1">Tab 1</a>
    <a href="#tab2">Tab 2</a>
    <a href="#tab3">Tab 3</a>
  </nav>

  <!-- Tab Panels -->
  <div class="tab-panels">
    <article id="tab1" class="tab-content">
      <h2>Content Panel 1</h2>
      <p>This is the first panel.</p>
    </article>
    <article id="tab2" class="tab-content">
      <h2>Content Panel 2</h2>
      <p>This is the second panel.</p>
    </article>
    <article id="tab3" class="tab-content">
      <h2>Content Panel 3</h2>
      <p>This is the third panel.</p>
    </article>
  </div>
</div>

Implementing the CSS Logic

The visibility of each tab panel is managed by switching display states depending on whether the element matches the :target selector:

/* Hide all panels by default */
.tab-content {
  display: none;
}

/* Display only the panel whose ID matches the current URL hash */
.tab-content:target {
  display: block;
}

When a user clicks the anchor link <a href="#tab2">Tab 2</a>, the URL hash updates to #tab2. The element <article id="tab2"> instantly matches :target, switching its property from display: none; to display: block;. Simultaneously, any previously targeted element loses its :target state and is hidden again.

Handling the Default Active Tab

A primary limitation of the :target pseudo-class is that no element is targeted when the page initially loads without a URL fragment. Developers handle this in one of two ways:

  1. URL Redirection or Default Hash: Linking users directly to the default state (e.g., loading page.html#tab1 directly).
  2. Sibling Combinator Overrides: Setting the first panel to display: block by default, then hiding it when any subsequent tab becomes targeted:
/* Show the first tab by default */
.tab-content:first-child {
  display: block;
}

/* If any tab is targeted, hide the default first tab */
.tab-panels:has(:target) .tab-content:first-child:not(:target) {
  display: none;
}

/* Show the targeted tab */
.tab-content:target {
  display: block;
}

Advantages and Considerations

Using :target for tabbed interfaces offers significant performance and functional benefits:

  • Zero JavaScript: The interface works even if JavaScript fails to load, is disabled, or is blocked by client-side settings.
  • Deep Linking: Users can share links directly to a specific tab, and bookmarking preserves the active tab state.
  • Native History Support: Each tab click generates a browser history entry, allowing users to navigate between tabs using the browser's Back and Forward buttons.

However, consider usability trade-offs before deploying this pattern:

  • Page Scrolling: Browsers automatically scroll the targeted element into view. If panels are positioned poorly, clicking a tab can cause an unwanted vertical jump.
  • History Pollution: Because every tab switch creates a new history entry, users must press the browser's back button multiple times to return to the previous website.
  • Accessibility: Native ARIA roles (role="tablist", role="tab", aria-selected="true") cannot be dynamically updated without JavaScript, requiring careful implementation to ensure assistive technologies can parse the interface properly.