CSS Tabs: 16 Examples, With and Without JavaScript

Browsers already track which radio is checked, which details element is open and which fragment is in the URL. CSS can select on all of it, enough for tabs with no click handler.

Below are 16 CSS tabs examples to try and copy, plus a guide to the no-JavaScript methods, their accessibility limits and the script that fills the gap.

By Bogdan Sandu · Updated October 27, 2026

CSS-only tab mechanisms

The four ways to switch panels with no script at all: radio inputs with sibling selectors, radio inputs read by :has(), the :target fragment, and an exclusive details group. Each note says what the method gives up in keyboard and screen reader support.

Radio tabs with a growing underline

  • :checked + label + panel
  • flex order
  • @starting-style

Hollow Ridge Loop

A full-day circuit over two granite saddles, with a lake halfway round and a steep forest descent back to the car park.

  • 14.2 kmdistance
  • 5 h 30moving time
  • Moderategrade

910 m of climbing

High point 1,240 m at km 6. The last 3 km lose 600 m on loose steps.

Pack list

  • Two litres of water, no refill before the lake
  • Wind shell for the second saddle
  • Poles for the forest descent
  • Head torch from October onwards

The build from the guide below: each radio input is followed by its label and its panel, flex-wrap puts the labels in a row and order: 1 drops the panels underneath. The underline scales out from the center and the panel fades in with @starting-style. Screen readers announce a radio group, not tabs, and the arrow keys select as they move.

View code
.tb-rad {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  width: min(100%, 560px);
  padding: 6px 10px 0;
  box-sizing: border-box;
  border-radius: 18px;
  background: #fff;
  color: #1c2430;
  box-shadow: 0 14px 34px rgba(15, 23, 42, .08);
}
.tb-rad__input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-rad__tab {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 48px;
  padding: 0 14px;
  border-radius: 10px 10px 0 0;
  color: #56606e;
  font-weight: 600;
  cursor: pointer;
  transition: color .2s ease, background-color .2s ease;
}
.tb-rad__tab::after {
  content: "";
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: 0;
  height: 3px;
  border-radius: 3px 3px 0 0;
  background: #0f766e;
  transform: scaleX(0);
}
.tb-rad__tab:hover {
  background: #f1f6f5;
  color: #1c2430;
}
.tb-rad__input:checked + .tb-rad__tab {
  color: #0b3d38;
}
.tb-rad__input:checked + .tb-rad__tab::after {
  transform: scaleX(1);
}
.tb-rad__input:focus-visible + .tb-rad__tab {
  outline: 2px solid #0f766e;
  outline-offset: -6px;
  border-radius: 12px;
}
.tb-rad__panel {
  display: none;
  order: 1;
  width: 100%;
  padding: 20px 8px 24px;
  border-top: 1px solid #e3e8ee;
}
.tb-rad__input:checked + .tb-rad__tab + .tb-rad__panel {
  display: block;
  opacity: 1;
}
.tb-rad__title {
  margin: 0 0 6px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 600;
  color: #0b3d38;
}
.tb-rad__text {
  margin: 0;
  color: #45505e;
  line-height: 1.55;
}
.tb-rad__facts {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}
.tb-rad__facts li {
  flex: 1 1 90px;
  padding: 10px 12px;
  border-radius: 12px;
  background: #eef5f3;
  color: #45505e;
  font-size: 13px;
}
.tb-rad__facts b {
  display: block;
  color: #0b3d38;
  font-size: 17px;
}
.tb-rad__profile {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  height: 90px;
  margin: 12px 0 14px;
  padding-bottom: 2px;
  border-bottom: 2px solid #c9d6d3;
}
.tb-rad__profile span {
  flex: 1;
  height: var(--tb-h);
  border-radius: 4px 4px 0 0;
  background: #14a38f;
}
.tb-rad__list {
  margin: 10px 0 0;
  padding-left: 20px;
  color: #45505e;
  line-height: 1.8;
}
@media (prefers-reduced-motion: no-preference) {
  .tb-rad__tab::after {
    transition: transform .25s ease;
  }
  .tb-rad__input:checked + .tb-rad__tab + .tb-rad__panel {
    transition: opacity .3s ease, display .3s allow-discrete;
  }
  @starting-style {
    .tb-rad__input:checked + .tb-rad__tab + .tb-rad__panel {
      opacity: 0;
    }
  }
}

Segmented control with a sliding thumb

  • :has(:checked)
  • custom property index
  • one-cell grid stack

Bergen, light rain

11°Feels like 8°, wind 6 m/s

  • 14:0011°
  • 15:0011°
  • 16:0010°
  • 17:009°
  • 18:008°

13°Dry until noon, showers after 15:00

  • Morning9°
  • Noon13°
  • Evening10°
  • Night7°
  • ThuRain12° / 7°
  • FriShowers13° / 8°
  • SatCloudy14° / 9°
  • SunSunny spells15° / 8°

The radio inputs live in the control and the panels in a separate container, which only :has() can connect. Each checked input sets an index custom property, and the thumb moves by that many widths. The panels share one grid cell and swap visibility, so the card keeps the height of the tallest panel and never jumps.

View code
.tb-seg {
  --tb-seg-i: 0;
  width: min(100%, 420px);
  padding: 20px;
  box-sizing: border-box;
  border-radius: 24px;
  background: #fff;
  color: #1f1d3a;
  box-shadow: 0 18px 40px rgba(31, 29, 58, .1);
}
.tb-seg__city {
  margin: 0 0 14px;
  color: #58557a;
  font-size: 14px;
  font-weight: 600;
}
.tb-seg__bar {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  padding: 4px;
  border-radius: 999px;
  background: #ebe8fa;
}
.tb-seg__input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-seg__tab {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  min-height: 40px;
  border-radius: 999px;
  color: #4b4870;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: color .2s ease;
}
.tb-seg__input:checked + .tb-seg__tab {
  background: #fff;
  color: #1f1d3a;
}
.tb-seg__input:focus-visible + .tb-seg__tab {
  outline: 2px solid #4f46e5;
  outline-offset: 2px;
}
@supports selector(:has(*)) {
  .tb-seg__bar::before {
    content: "";
    position: absolute;
    top: 4px;
    bottom: 4px;
    left: 4px;
    width: calc((100% - 8px) / 3);
    border-radius: 999px;
    background: #fff;
    box-shadow: 0 2px 8px rgba(31, 29, 58, .16);
    transform: translateX(calc(var(--tb-seg-i) * 100%));
  }
  .tb-seg__input:checked + .tb-seg__tab {
    background: none;
  }
}
.tb-seg:has(#tb-seg-2:checked) {
  --tb-seg-i: 1;
}
.tb-seg:has(#tb-seg-3:checked) {
  --tb-seg-i: 2;
}
.tb-seg__panels {
  display: grid;
  margin-top: 18px;
}
.tb-seg__panel {
  grid-area: 1 / 1;
  visibility: hidden;
}
.tb-seg__panel:first-child {
  visibility: visible;
}
.tb-seg:has(#tb-seg-2:checked) #tb-seg-p1,
.tb-seg:has(#tb-seg-3:checked) #tb-seg-p1 {
  visibility: hidden;
}
.tb-seg:has(#tb-seg-2:checked) #tb-seg-p2,
.tb-seg:has(#tb-seg-3:checked) #tb-seg-p3 {
  visibility: visible;
}
.tb-seg__big {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 4px 12px;
  margin: 0 0 16px;
}
.tb-seg__big b {
  font-family: "Fraunces", Georgia, serif;
  font-size: 54px;
  font-weight: 500;
  line-height: 1;
}
.tb-seg__big span {
  color: #58557a;
  font-size: 14px;
  line-height: 1.4;
}
.tb-seg__hours {
  display: flex;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tb-seg__hours li {
  flex: 1;
  display: grid;
  gap: 4px;
  padding: 10px 4px;
  border-radius: 14px;
  background: #f5f4fd;
  text-align: center;
  font-size: 12px;
  color: #58557a;
}
.tb-seg__hours b {
  color: #1f1d3a;
  font-size: 16px;
}
.tb-seg__week {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tb-seg__week li {
  display: grid;
  grid-template-columns: 44px 1fr auto;
  gap: 8px;
  padding: 9px 0;
  border-bottom: 1px solid #ebe8fa;
  font-size: 14px;
  color: #58557a;
}
.tb-seg__week b {
  color: #1f1d3a;
}
@media (prefers-reduced-motion: no-preference) {
  .tb-seg__bar::before {
    transition: transform .3s cubic-bezier(.3, .7, .2, 1);
  }
}

Deep-linkable :target tabs

  • :target
  • :has() default panel
  • scroll-margin-top

Orders leave our workshop in 2 working days

Standard delivery takes 3 to 5 days inside the EU and is free over 80 euros. Express arrives the next working day if you order before 13:00.

30 days to change your mind

Print the prepaid label from your order page, pack the item in its original sleeve and drop it at any parcel point. Refunds land within 5 working days.

Two years on every stitch

If a seam or zip fails in normal use, we repair it or send a replacement. Wear and tear from heavy loads is not covered, but repairs at cost always are.

Each tab is a plain link to a panel id, and .panel:target shows the panel named in the URL, so every tab can be bookmarked and Back steps through earlier tabs. :has() styles the matching link and shows the first panel while nothing is targeted. Clicking still jumps the page to the panel (scroll-margin-top keeps the tab row in view) and adds a history entry per click.

View code
.tb-tg {
  width: min(100%, 540px);
  padding: 18px 16px 22px;
  box-sizing: border-box;
  border: 1px solid #e2ddd3;
  border-radius: 16px;
  background: #fffdf9;
  color: #2b2620;
}
.tb-tg__url {
  margin: 0 0 14px;
  padding: 7px 12px;
  border-radius: 8px;
  background: #f1ece2;
  color: #6b6257;
  font: 13px/1.4 ui-monospace, Consolas, monospace;
  overflow-wrap: anywhere;
}
.tb-tg__url::after {
  content: "";
  color: #2b2620;
}
.tb-tg:has(#tb-tg-ship:target) .tb-tg__url::after {
  content: "#tb-tg-ship";
}
.tb-tg:has(#tb-tg-ret:target) .tb-tg__url::after {
  content: "#tb-tg-ret";
}
.tb-tg:has(#tb-tg-war:target) .tb-tg__url::after {
  content: "#tb-tg-war";
}
.tb-tg__nav {
  display: flex;
  flex-wrap: wrap;
  border-bottom: 2px solid #e2ddd3;
}
.tb-tg__link {
  margin-bottom: -2px;
  padding: 12px 10px;
  border-bottom: 2px solid transparent;
  color: #6b6257;
  font-weight: 600;
  text-decoration: none;
  transition: color .2s ease, border-color .2s ease;
}
.tb-tg__link:hover {
  color: #2b2620;
  border-bottom-color: #cfc6b8;
}
.tb-tg__link:focus-visible {
  outline: 2px solid #b4441c;
  outline-offset: 2px;
  border-radius: 6px;
}
.tb-tg:has(#tb-tg-ship:target) [href="#tb-tg-ship"],
.tb-tg:has(#tb-tg-ret:target) [href="#tb-tg-ret"],
.tb-tg:has(#tb-tg-war:target) [href="#tb-tg-war"],
.tb-tg:not(:has(:target)) [href="#tb-tg-ship"] {
  color: #b4441c;
  border-bottom-color: #b4441c;
}
.tb-tg__panel {
  display: none;
  padding-top: 18px;
  scroll-margin-top: 140px;
  line-height: 1.6;
  color: #4a4238;
}
.tb-tg__panel p {
  margin: 0;
}
.tb-tg__panel:target,
.tb-tg:not(:has(:target)) #tb-tg-ship {
  display: block;
}
.tb-tg__title {
  margin-bottom: 6px !important;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
  color: #2b2620;
}

Exclusive details group laid out as tabs

  • details name
  • absolute panels
  • pointer-events
Espresso
  • Cortado3.40
  • Flat white3.90
  • Double ristretto3.20
  • Oat cappuccino4.20
Filter
  • Kenya, Kirinyaga4.50
  • Colombia, Huila4.20
  • Batch brew2.80
Tea
  • Sencha3.20
  • Smoked black3.20
  • Fresh mint2.90

Three details elements share one name, so the browser closes the open one when another opens. The summaries form the tab row and each body is positioned under the row, which is why the card needs a fixed minimum height. pointer-events: none on the open summary stops a click from closing it, but Enter or Space still can, and the implicit role stays group, never tab.

View code
.tb-dt {
  position: relative;
  display: flex;
  gap: 6px;
  width: min(100%, 480px);
  min-height: 290px;
  padding: 10px;
  box-sizing: border-box;
  border-radius: 22px;
  background: #3b2417;
  color: #3b2417;
}
.tb-dt__item {
  flex: 1 1 0;
  min-width: 0;
}
.tb-dt__tab {
  display: grid;
  place-items: center;
  min-height: 46px;
  border-radius: 14px;
  color: #f4e7d6;
  font-weight: 600;
  list-style: none;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.tb-dt__tab::-webkit-details-marker {
  display: none;
}
.tb-dt__tab:hover {
  background: #54362a;
}
.tb-dt__tab:focus-visible {
  outline: 2px solid #f2b36d;
  outline-offset: 2px;
}
.tb-dt__item[open] > .tb-dt__tab {
  background: #fbf3e8;
  color: #3b2417;
  pointer-events: none;
}
.tb-dt__body {
  position: absolute;
  top: 66px;
  right: 10px;
  bottom: 10px;
  left: 10px;
  padding: 18px 20px;
  border-radius: 16px;
  background: #fbf3e8;
}
.tb-dt__menu {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tb-dt__menu li {
  display: flex;
  align-items: baseline;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px dashed #d8c3a8;
  font-family: "Fraunces", Georgia, serif;
  font-size: 17px;
}
.tb-dt__menu li:last-child {
  border-bottom: 0;
}
.tb-dt__menu span {
  flex: 1;
}
.tb-dt__menu b {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 15px;
  font-weight: 600;
  color: #7a4a2c;
}
@media (prefers-reduced-motion: no-preference) {
  .tb-dt__item[open] .tb-dt__body {
    animation: tb-dt-in .3s ease;
  }
  @keyframes tb-dt-in {
    from {
      opacity: 0;
      translate: 0 6px;
    }
  }
}

Accessible tabs with a small script

Full WAI-ARIA tabs: tablist, tab and tabpanel roles, aria-selected, a roving tabindex, arrow keys plus Home and End. CSS still does all the visual work, and the script only writes attributes and moves focus.

APG tabs with an anchored ink bar

  • automatic activation
  • anchor positioning
  • roving tabindex
  • Mira moved Homepage hero to Review12 min
  • Tomas commented on Pricing table1 h
  • Ada closed Footer links audit3 h

Click a tab, or focus one and press the arrow keys, Home or End.

Automatic activation, as the APG recommends when panels are already in the DOM: arrow keys move focus and select in one step. The selected tab carries anchor-name, and one ink bar is anchored to its left and right edges, so it slides between tabs of any width. Browsers without anchor positioning get a plain inset underline on the selected tab.

View code
.tb-ul {
  width: min(100%, 520px);
  padding: 8px 20px 20px;
  box-sizing: border-box;
  border-radius: 18px;
  background: #fff;
  color: #141a2e;
  box-shadow: 0 14px 36px rgba(20, 26, 46, .09);
}
.tb-ul__list {
  position: relative;
  display: flex;
  gap: 4px;
  border-bottom: 1px solid #e2e6f0;
}
.tb-ul__tab {
  min-height: 48px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px 8px 0 0;
  background: none;
  color: #525c78;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: color .2s ease, background-color .2s ease;
}
.tb-ul__tab:hover {
  background: #f3f5fb;
  color: #141a2e;
}
.tb-ul__tab[aria-selected="true"] {
  color: #2440d8;
  box-shadow: inset 0 -3px 0 #2440d8;
}
.tb-ul__tab:focus-visible {
  outline: 2px solid #2440d8;
  outline-offset: -4px;
  border-radius: 10px;
}
.tb-ul__ink {
  display: none;
}
@supports (anchor-name: --tb-ul-a) {
  .tb-ul__tab[aria-selected="true"] {
    anchor-name: --tb-ul-active;
    box-shadow: none;
  }
  .tb-ul__ink {
    position: absolute;
    display: block;
    position-anchor: --tb-ul-active;
    left: anchor(left);
    right: anchor(right);
    bottom: -1px;
    height: 3px;
    border-radius: 3px 3px 0 0;
    background: #2440d8;
  }
}
.tb-ul__panel {
  padding-top: 16px;
}
.tb-ul__panel[hidden] {
  display: none;
}
.tb-ul__panel:focus-visible {
  outline: 2px solid #2440d8;
  outline-offset: 4px;
  border-radius: 8px;
}
.tb-ul__feed {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tb-ul__feed li {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 2px 6px;
  padding: 12px 0;
  border-bottom: 1px solid #eef1f7;
  color: #525c78;
  font-size: 15px;
}
.tb-ul__feed b {
  color: #141a2e;
}
.tb-ul__feed i {
  color: #141a2e;
  font-style: normal;
  font-weight: 500;
}
.tb-ul__feed span {
  margin-left: auto;
  color: #6b7490;
  font-size: 13px;
}
@media (prefers-reduced-motion: no-preference) {
  .tb-ul__ink {
    transition: left .3s ease, right .3s ease;
  }
}

Manual activation for code samples

  • manual activation
  • Enter / Space
  • scrollable tabpanel
const res = await fetch("/api/orders?status=open");
const { items } = await res.json();
console.log(items.length, "open orders");

Click a tab, or focus one and press the arrow keys, Home or End.

The manual variant of the APG pattern: arrow keys only move focus, and Enter or Space selects. It suits panels that are slow to render or that move the page, and it lets a keyboard user pass over tabs without swapping the code. The panel is focusable, so a long line can be scrolled with the arrow keys.

View code
.tb-code {
  container-type: inline-size;
  width: min(100%, 560px);
  overflow: hidden;
  border-radius: 16px;
  background: #12161f;
  color: #d9dee8;
  box-shadow: 0 18px 40px rgba(10, 14, 22, .28);
}
.tb-code__top {
  padding: 8px 8px 0;
  background: #1b212d;
}
.tb-code__list {
  display: flex;
  gap: 2px;
}
.tb-code__tab {
  min-height: 42px;
  padding: 0 14px;
  border: 0;
  border-radius: 10px 10px 0 0;
  background: none;
  color: #a7b0c2;
  font: 600 14px/1 ui-monospace, Consolas, monospace;
  cursor: pointer;
  transition: color .2s ease, background-color .2s ease;
}
.tb-code__tab:hover {
  color: #fff;
  background: #252c3a;
}
.tb-code__tab[aria-selected="true"] {
  background: #12161f;
  color: #ffd479;
}
.tb-code__tab:focus-visible {
  outline: 2px solid #ffd479;
  outline-offset: -4px;
}
.tb-code__panel {
  margin: 0;
  padding: 20px 22px 24px;
  overflow-x: auto;
  min-height: 92px;
  font: 14px/1.7 ui-monospace, Consolas, monospace;
  tab-size: 2;
}
.tb-code__panel[hidden] {
  display: none;
}
.tb-code__panel:focus-visible {
  outline: 2px solid #ffd479;
  outline-offset: -4px;
}
.tb-code__k {
  color: #c4a7ff;
}
.tb-code__f {
  color: #7fd1ff;
}
.tb-code__s {
  color: #a5e07a;
}

Vertical settings tabs that flip horizontal

  • aria-orientation
  • container query
  • ResizeObserver

Profile

Click a tab, or focus one and press the arrow keys, Home or End.

A settings screen with the tabs in a side column, where Up and Down move between them. Below 520px of container width the column turns into a scrolling row, and a ResizeObserver switches aria-orientation and the arrow keys to match the layout. The panels hold real labelled controls, so they need no tabindex of their own.

View code
.tb-vt {
  container-type: inline-size;
  width: min(100%, 640px);
}
.tb-vt__wrap {
  display: grid;
  overflow: hidden;
  border: 1px solid #e1e3ea;
  border-radius: 18px;
  background: #fff;
  color: #1d2230;
}
.tb-vt__list {
  display: flex;
  gap: 4px;
  padding: 10px;
  overflow-x: auto;
  border-bottom: 1px solid #e1e3ea;
  background: #f7f7fa;
  scrollbar-width: thin;
}
.tb-vt__tab {
  flex: none;
  min-height: 40px;
  padding: 0 14px;
  border: 0;
  border-radius: 10px;
  background: none;
  color: #4f566b;
  font: inherit;
  font-size: 15px;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.tb-vt__tab:hover {
  background: #ececf3;
  color: #1d2230;
}
.tb-vt__tab[aria-selected="true"] {
  background: #fff;
  color: #5b21b6;
  font-weight: 700;
  box-shadow: 0 1px 3px rgba(29, 34, 48, .14), inset 3px 0 0 #6d28d9;
}
.tb-vt__tab:focus-visible {
  outline: 2px solid #6d28d9;
  outline-offset: 2px;
}
.tb-vt__panel {
  display: grid;
  align-content: start;
  gap: 14px;
  padding: 22px;
}
.tb-vt__panel[hidden] {
  display: none;
}
.tb-vt__title {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 600;
}
.tb-vt__field {
  display: grid;
  gap: 6px;
  color: #4f566b;
  font-size: 14px;
  font-weight: 500;
}
.tb-vt__field input {
  min-height: 42px;
  padding: 0 12px;
  border: 1px solid #c9cdd8;
  border-radius: 10px;
  color: #1d2230;
  font: inherit;
  font-size: 15px;
}
.tb-vt__field input:focus-visible {
  outline: 2px solid #6d28d9;
  outline-offset: 1px;
  border-color: #6d28d9;
}
.tb-vt__switch {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 32px;
  color: #1d2230;
  font-size: 15px;
  cursor: pointer;
}
.tb-vt__switch input {
  appearance: none;
  position: relative;
  flex: none;
  width: 44px;
  height: 26px;
  margin: 0;
  border-radius: 999px;
  background: #b9bdca;
  cursor: pointer;
  transition: background-color .2s ease;
}
.tb-vt__switch input::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgba(0, 0, 0, .2);
}
.tb-vt__switch input:checked {
  background: #6d28d9;
}
.tb-vt__switch input:checked::after {
  translate: 18px 0;
}
.tb-vt__switch input:focus-visible {
  outline: 2px solid #6d28d9;
  outline-offset: 2px;
}
.tb-vt__plan {
  margin: 0;
  padding: 14px 16px;
  border-radius: 12px;
  background: #f4f0fd;
  color: #4f566b;
  line-height: 1.5;
}
.tb-vt__plan b {
  display: block;
  color: #1d2230;
}
.tb-vt__btn {
  justify-self: start;
  min-height: 44px;
  padding: 0 18px;
  border: 0;
  border-radius: 10px;
  background: #6d28d9;
  color: #fff;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.tb-vt__btn:focus-visible {
  outline: 2px solid #6d28d9;
  outline-offset: 3px;
}
@container (min-width: 520px) {
  .tb-vt__wrap {
    grid-template-columns: 190px 1fr;
    min-height: 280px;
  }
  .tb-vt__list {
    flex-direction: column;
    overflow: visible;
    border-bottom: 0;
    border-right: 1px solid #e1e3ea;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .tb-vt__switch input::after {
    transition: translate .2s ease;
  }
}

Thumbnail tabs for a photo story

  • image tabs
  • aria-selected styling
  • object-fit
Terraced green hillside dropping to a calm sea, with a road cut into the slope above the water
The cliff road212 km, 2 days, 6 viewpoints

Click a tab, or focus one and press the arrow keys, Home or End.

Each tab is a thumbnail plus a visible text label, and the text gives the tab its accessible name, so the thumbnail image can stay decorative with an empty alt. The selected thumbnail gets a thick dark frame and a lift, which survives forced colors better than a color change alone.

View code
.tb-ph {
  display: grid;
  gap: 16px;
  width: min(100%, 560px);
}
.tb-ph__list {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
}
.tb-ph__tab {
  display: grid;
  gap: 6px;
  padding: 5px 5px 8px;
  border: 0;
  border-radius: 14px;
  background: #fff;
  color: #3a3f47;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 0 0 1px #dcdfe5;
  transition: box-shadow .2s ease, color .2s ease;
}
.tb-ph__tab img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 10px;
}
.tb-ph__tab:hover {
  box-shadow: 0 0 0 1px #9aa0ab;
}
.tb-ph__tab[aria-selected="true"] {
  color: #111418;
  box-shadow: 0 0 0 3px #111418, 0 10px 22px rgba(17, 20, 24, .18);
}
.tb-ph__tab:focus-visible {
  outline: 3px solid #d9480f;
  outline-offset: 4px;
}
.tb-ph__panel {
  position: relative;
  overflow: hidden;
  border-radius: 18px;
  background: #111418;
}
.tb-ph__panel[hidden] {
  display: none;
}
.tb-ph__panel:focus-visible {
  outline: 3px solid #d9480f;
  outline-offset: 3px;
}
.tb-ph__panel img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}
.tb-ph__cap {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 12px;
  padding: 14px 18px;
  color: #e8eaee;
  font-size: 14px;
}
.tb-ph__cap b {
  color: #fff;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  font-weight: 600;
}
@media (prefers-reduced-motion: no-preference) {
  .tb-ph__tab[aria-selected="true"] {
    translate: 0 -3px;
  }
  .tb-ph__tab {
    transition: box-shadow .2s ease, color .2s ease, translate .2s ease;
  }
}

Dark dashboard tabs with counts

  • count badges
  • automatic activation
  • CSS bar chart

Uptime99.98%

p95 latency182 ms

Errors0.04%

Click a tab, or focus one and press the arrow keys, Home or End.

Counts sit inside the tab, so the accessible name reads Incidents 3 and nobody has to guess what the badge means. The selected tab is a raised pill on a dark track, and the bar chart is plain divs sized by a custom property, with a text summary for screen readers.

View code
.tb-db {
  container-type: inline-size;
  width: min(100%, 560px);
  padding: 16px;
  box-sizing: border-box;
  border: 1px solid #232a3a;
  border-radius: 20px;
  background: #121826;
  color: #e6e9f0;
}
.tb-db__list {
  display: flex;
  gap: 4px;
  padding: 4px;
  border-radius: 14px;
  background: #0b0f1a;
}
.tb-db__tab {
  display: inline-flex;
  flex: 1 1 auto;
  align-items: center;
  justify-content: center;
  gap: 6px;
  min-height: 42px;
  padding: 0 6px;
  white-space: nowrap;
  border: 0;
  border-radius: 10px;
  background: none;
  color: #a3abbd;
  font: inherit;
  font-size: clamp(13px, 3.6cqi, 14px);
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.tb-db__tab:hover {
  color: #fff;
}
.tb-db__tab[aria-selected="true"] {
  background: #263049;
  color: #fff;
  box-shadow: inset 0 1px 0 #3a4766;
}
.tb-db__tab:focus-visible {
  outline: 2px solid #c6f36b;
  outline-offset: 2px;
}
.tb-db__count {
  min-width: 22px;
  padding: 2px 6px;
  box-sizing: border-box;
  border-radius: 999px;
  background: #33405c;
  color: #e6e9f0;
  font-size: 12px;
  line-height: 1.4;
}
.tb-db__count--hot {
  background: #ff8a65;
  color: #1a0d08;
}
.tb-db__panel {
  padding: 18px 4px 4px;
}
.tb-db__panel[hidden] {
  display: none;
}
.tb-db__panel:focus-visible {
  outline: 2px solid #c6f36b;
  outline-offset: 2px;
  border-radius: 10px;
}
.tb-db__kpis {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
  gap: 8px;
}
.tb-db__kpis p {
  display: grid;
  gap: 2px;
  margin: 0;
  padding: 10px 12px;
  border-radius: 12px;
  background: #1a2133;
}
.tb-db__kpis span {
  color: #a3abbd;
  font-size: 12px;
}
.tb-db__kpis b {
  font-size: 20px;
}
.tb-db__chart {
  display: flex;
  align-items: flex-end;
  gap: 5px;
  height: 110px;
  margin-top: 16px;
  padding: 10px 12px 0;
  border-radius: 12px;
  background: #1a2133;
}
.tb-db__chart span {
  flex: 1;
  height: var(--tb-v);
  border-radius: 4px 4px 0 0;
  background: #c6f36b;
}
.tb-db__rows {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tb-db__rows li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 10px;
  padding: 12px 0;
  border-bottom: 1px solid #232a3a;
  font-size: 14px;
}
.tb-db__rows time {
  margin-left: auto;
  color: #a3abbd;
  font-size: 13px;
}
.tb-db__sev {
  padding: 2px 8px;
  border-radius: 6px;
  background: #2a3349;
  color: #cfd5e2;
  font-size: 12px;
  font-weight: 600;
}
.tb-db__sev--high {
  background: #ff8a65;
  color: #1a0d08;
}
.tb-db__sev--ok {
  background: #c6f36b;
  color: #142003;
}

Real-world tab designs

Tabs as they show up in shipping products: a pricing toggle, a product page, browser tabs and folder tabs. All four are CSS-only and use the :has() layout, so the tab bar and the panels can sit in separate containers.

Monthly and yearly pricing toggle

  • fieldset + legend
  • :has(:checked)
  • tabular-nums
Billing period

Starter

$12$10per month

Billed monthly$120 billed yearly

  • 3 projects
  • Invoices and receipts
  • Email support

Team

$29$24per month

Billed monthly$290 billed yearly

  • Unlimited projects
  • Shared ledgers for 10 people
  • Priority chat support

A two-option tab switch that changes every price on the page at once. The radios sit in a fieldset with a legend, which is the honest semantics here: this is a choice between two values, not a set of panels. :has() on the wrapper swaps each monthly price for its yearly twin.

View code
.tb-pr {
  display: grid;
  justify-items: center;
  gap: 22px;
  width: min(100%, 600px);
  color: #17201b;
}
.tb-pr__switch {
  position: relative;
  display: flex;
  gap: 4px;
  margin: 0;
  padding: 5px;
  border: 0;
  border-radius: 999px;
  background: #dfe8e2;
}
.tb-pr__legend {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.tb-pr__opt {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 18px;
  border-radius: 999px;
  color: #3f4b44;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.tb-pr__opt input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-pr__opt:has(input:checked) {
  background: #17201b;
  color: #fff;
}
.tb-pr__opt:has(input:focus-visible) {
  outline: 2px solid #17201b;
  outline-offset: 3px;
}
.tb-pr__save {
  padding: 2px 8px;
  border-radius: 999px;
  background: #ffd84d;
  color: #2c2200;
  font-size: 12px;
}
.tb-pr__plans {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 14px;
  width: 100%;
}
.tb-pr__plan {
  padding: 22px;
  border: 1px solid #d5ddd8;
  border-radius: 18px;
  background: #fff;
}
.tb-pr__plan--main {
  border: 2px solid #17201b;
}
.tb-pr__name {
  margin: 0;
  font-weight: 700;
}
.tb-pr__price {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin: 10px 0 2px;
  line-height: 1;
}
.tb-pr__price span {
  font-family: "Fraunces", Georgia, serif;
  font-size: 44px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.tb-pr__price small {
  color: #56635b;
  font-size: 14px;
}
.tb-pr__bill {
  margin: 0 0 14px;
  color: #56635b;
  font-size: 14px;
}
.tb-pr__y {
  display: none;
}
.tb-pr:has(#tb-pr-year:checked) .tb-pr__y {
  display: inline;
}
.tb-pr:has(#tb-pr-year:checked) .tb-pr__m {
  display: none;
}
.tb-pr__list {
  margin: 0;
  padding: 14px 0 0 18px;
  border-top: 1px solid #e4eae6;
  color: #3f4b44;
  font-size: 14px;
  line-height: 1.9;
}

Product page tabs with a review count

  • container query
  • :has() panels
  • definition list
Art print of a deep blue fjord between grey granite cliffs, seen from a rocky ledge

Fjord at First Light

64 euros Giclee print, signed

Shot from a ledge above the water at 5 a.m., printed on 310 gsm cotton rag with archival pigment inks. Each print is numbered and ships flat in a rigid sleeve.

Size
40 x 30 cm
Paper
Cotton rag, 310 gsm
Edition
Open, numbered
Frame
Not included

4.8 out of 5, from 128 reviews

The blues are even deeper in person. Arrived perfectly flat. Jonas, Oslo

Description, specs and reviews for a print shop, with the photo beside the tabs on wide cards and above them on narrow ones. The review count sits inside the label as text, and the spec sheet is a definition list laid out on a two-column grid.

View code
.tb-pd {
  container-type: inline-size;
  width: min(100%, 780px);
}
.tb-pd__card {
  display: grid;
  overflow: hidden;
  border-radius: 20px;
  background: #fff;
  color: #1b2026;
  box-shadow: 0 16px 40px rgba(27, 32, 38, .1);
}
.tb-pd__img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.tb-pd__info {
  padding: 22px 18px;
}
.tb-pd__name {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
  line-height: 1.15;
}
.tb-pd__price {
  margin: 6px 0 18px;
  font-weight: 700;
}
.tb-pd__price span {
  margin-left: 6px;
  color: #5e6670;
  font-weight: 400;
  font-size: 14px;
}
.tb-pd__tabs {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: 0 14px;
  border-bottom: 1px solid #e1e4e8;
}
.tb-pd__input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-pd__tab {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  margin-bottom: -1px;
  border-bottom: 2px solid transparent;
  color: #5e6670;
  font-weight: 600;
  cursor: pointer;
  transition: color .2s ease, border-color .2s ease;
}
.tb-pd__tab:hover {
  color: #1b2026;
}
.tb-pd__input:checked + .tb-pd__tab {
  color: #1b2026;
  border-bottom-color: #1b2026;
}
.tb-pd__input:focus-visible + .tb-pd__tab {
  outline: 2px solid #0b62d6;
  outline-offset: 2px;
  border-radius: 4px;
}
.tb-pd__count {
  padding: 1px 7px;
  border-radius: 999px;
  background: #eef0f3;
  color: #1b2026;
  font-size: 12px;
}
.tb-pd__panel {
  display: none;
  padding-top: 16px;
  color: #3e4650;
  font-size: 15px;
  line-height: 1.6;
}
.tb-pd__panel p {
  margin: 0;
}
#tb-pd-p1,
.tb-pd:has(#tb-pd-2:checked) #tb-pd-p2,
.tb-pd:has(#tb-pd-3:checked) #tb-pd-p3 {
  display: block;
}
.tb-pd:has(#tb-pd-2:checked) #tb-pd-p1,
.tb-pd:has(#tb-pd-3:checked) #tb-pd-p1 {
  display: none;
}
.tb-pd__specs {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 8px 18px;
  margin: 0;
}
.tb-pd__specs dt {
  color: #5e6670;
}
.tb-pd__specs dd {
  margin: 0;
  color: #1b2026;
  font-weight: 500;
}
.tb-pd__score b {
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  color: #1b2026;
}
.tb-pd__quote {
  margin: 12px 0 0;
  padding: 12px 14px;
  border-left: 3px solid #1b2026;
  background: #f5f6f8;
}
.tb-pd__quote cite {
  display: block;
  margin-top: 4px;
  color: #5e6670;
  font-size: 13px;
  font-style: normal;
}
@container (min-width: 600px) {
  .tb-pd__card {
    grid-template-columns: 1fr 1.1fr;
  }
  .tb-pd__img {
    height: 100%;
    aspect-ratio: auto;
  }
  .tb-pd__info {
    min-height: 340px;
    padding: 28px;
  }
}

Browser-style tabs with curved feet

  • box-shadow corners
  • text-overflow
  • :has() panels
fieldjournal.example/autumn tidewater.example/charts pantry.example/basket

Ten walks for short days

Low routes that finish before dark, chosen by readers.

Tide tables, Saltmarsh Bay

High water 06:12 and 18:34. Spring tide on Sunday.

Your basket, 7 items

Delivery slot Thursday, 18:00 to 20:00.

The active tab merges into the toolbar through two small pseudo-elements whose rounded corners are filled by an offset box-shadow, no gradient needed. Titles shrink with min-width: 0 and end in an ellipsis, so three tabs still fit at phone width.

View code
.tb-br {
  container-type: inline-size;
  width: min(100%, 600px);
  overflow: hidden;
  border: 1px solid #c9ced8;
  border-radius: 14px;
  background: #fff;
  color: #1f2430;
  box-shadow: 0 16px 36px rgba(31, 36, 48, .12);
}
.tb-br__strip {
  display: flex;
  gap: 2px;
  padding: 8px 10px 0;
  background: #dfe3ea;
}
.tb-br__tab {
  position: relative;
  display: flex;
  flex: 0 1 190px;
  align-items: center;
  gap: 8px;
  min-width: 0;
  min-height: 38px;
  padding: 0 12px;
  border-radius: 10px 10px 0 0;
  color: #444b59;
  font-size: 13px;
  cursor: pointer;
  transition: background-color .2s ease;
}
.tb-br__tab input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-br__tab span {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.tb-br__tab:hover {
  background: #cfd4dd;
}
.tb-br__tab:has(input:checked) {
  z-index: 1;
  background: #fff;
  color: #1f2430;
  font-weight: 600;
}
.tb-br__tab:has(input:checked)::before,
.tb-br__tab:has(input:checked)::after {
  content: "";
  position: absolute;
  bottom: 0;
  width: 10px;
  height: 10px;
  pointer-events: none;
}
.tb-br__tab:has(input:checked)::before {
  left: -10px;
  border-bottom-right-radius: 10px;
  box-shadow: 4px 4px 0 4px #fff;
}
.tb-br__tab:has(input:checked)::after {
  right: -10px;
  border-bottom-left-radius: 10px;
  box-shadow: -4px 4px 0 4px #fff;
}
.tb-br__tab:has(input:focus-visible) {
  outline: 2px solid #1a73e8;
  outline-offset: -3px;
}
.tb-br__fav {
  display: grid;
  flex: none;
  place-items: center;
  width: 16px;
  height: 16px;
  border-radius: 4px;
  background: #2e6b4f;
  color: #fff;
  font: inherit;
  font-size: 10px;
  font-weight: 700;
  line-height: 1;
  font-style: normal;
}
.tb-br__fav--b {
  background: #1f5fa8;
}
.tb-br__fav--c {
  background: #b3471d;
}
.tb-br__bar {
  padding: 8px 12px;
  border-bottom: 1px solid #e3e6ec;
}
.tb-br__addr {
  display: none;
  padding: 6px 14px;
  border-radius: 999px;
  background: #eef1f5;
  color: #444b59;
  font-size: 13px;
  overflow-wrap: anywhere;
}
.tb-br__page {
  display: none;
  min-height: 150px;
  padding: 22px 24px;
  box-sizing: border-box;
  color: #4a5160;
  line-height: 1.55;
}
.tb-br__page p {
  margin: 0;
}
.tb-br__h {
  margin-bottom: 6px !important;
  color: #1f2430;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 600;
}
.tb-br__addr--1,
.tb-br__page--1 {
  display: block;
}
.tb-br:has(#tb-br-2:checked) .tb-br__addr--1,
.tb-br:has(#tb-br-3:checked) .tb-br__addr--1,
.tb-br:has(#tb-br-2:checked) .tb-br__page--1,
.tb-br:has(#tb-br-3:checked) .tb-br__page--1 {
  display: none;
}
.tb-br:has(#tb-br-2:checked) .tb-br__addr--2,
.tb-br:has(#tb-br-3:checked) .tb-br__addr--3,
.tb-br:has(#tb-br-2:checked) .tb-br__page--2,
.tb-br:has(#tb-br-3:checked) .tb-br__page--3 {
  display: block;
}
.tb-br__page--2 {
  background: #f1f6fb;
}
.tb-br__page--3 {
  background: #fdf5f0;
}

Folder tabs on a recipe card

  • overlapping tabs
  • z-index stacking
  • :has() panels

Charred leek tart

  • 4 young leeks
  • 1 sheet puff pastry
  • 150 g creme fraiche
  • 80 g aged cheddar
  • 2 eggs
  • Thyme, black pepper
  1. Char the leeks whole in a dry pan, then slice.
  2. Whisk the eggs, creme fraiche and half the cheese.
  3. Spread over the pastry, top with leeks and bake 25 minutes at 200°C.

Works with spring onions in May. Rest it 10 minutes before cutting, or the custard runs.

Index-card tabs that overlap like folder dividers. Each label is pulled under its left neighbour with a negative margin, and the checked one is raised with z-index and the card color, so it reads as the front folder. The panel border stops exactly where the active tab sits on it.

View code
.tb-fd {
  width: min(100%, 500px);
  color: #2f2a22;
}
.tb-fd__tabs {
  display: flex;
  padding-left: 14px;
}
.tb-fd__tab {
  position: relative;
  display: grid;
  place-items: center;
  min-height: 44px;
  margin-right: -10px;
  padding: 0 22px;
  border: 1px solid #cdbf9f;
  border-bottom: 0;
  border-radius: 12px 12px 0 0;
  background: #eadfc4;
  color: #5a4f3c;
  font-family: "Fraunces", Georgia, serif;
  font-size: 16px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.tb-fd__tab:nth-child(2) {
  background: #e2d4b3;
}
.tb-fd__tab:nth-child(3) {
  background: #d9c9a4;
}
.tb-fd__tab input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-fd__tab:hover {
  color: #2f2a22;
}
.tb-fd__tab:has(input:checked) {
  z-index: 2;
  margin-bottom: -1px;
  padding-bottom: 1px;
  background: #fffaf0;
  color: #2f2a22;
}
.tb-fd__tab:has(input:focus-visible) {
  outline: 2px solid #8a3b12;
  outline-offset: -5px;
}
.tb-fd__card {
  position: relative;
  z-index: 1;
  padding: 22px 24px 26px;
  border: 1px solid #cdbf9f;
  border-radius: 4px 16px 16px 16px;
  background: #fffaf0;
  box-shadow: 0 14px 30px rgba(74, 60, 30, .12);
}
.tb-fd__title {
  margin: 0 0 14px;
  padding-bottom: 12px;
  border-bottom: 1px solid #e5d9bf;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
}
.tb-fd__panel {
  display: none;
  line-height: 1.6;
  color: #4b4334;
}
.tb-fd__panel p {
  margin: 0;
}
#tb-fd-p1,
.tb-fd:has(#tb-fd-2:checked) #tb-fd-p2,
.tb-fd:has(#tb-fd-3:checked) #tb-fd-p3 {
  display: block;
}
.tb-fd:has(#tb-fd-2:checked) #tb-fd-p1,
.tb-fd:has(#tb-fd-3:checked) #tb-fd-p1 {
  display: none;
}
.tb-fd__ing {
  columns: 2 140px;
  margin: 0;
  padding-left: 18px;
}
.tb-fd__steps {
  margin: 0;
  padding-left: 20px;
}
.tb-fd__steps li + li {
  margin-top: 6px;
}

Tabs on small screens

Three answers to the narrow viewport: turn the tabs into an accordion with a container query, keep one row that scrolls sideways with scroll snapping, or move the tabs into an app-style bottom bar.

Tabs that become an accordion

  • @container
  • flex order
  • resize: horizontal

Tuesday to Sunday, 10:00 to 18:00. Late opening on Thursdays until 21:00. Closed on Mondays.

Adults 16 euros, students 9 euros, under 18s free. Timed entry slots open 30 days ahead.

Tram 4 to Harbour Gate, then a five-minute walk along the quay. Bike racks by the north entrance.

Step-free entrance, lifts to all floors, and wheelchairs to borrow at the desk. Quiet hours on Sunday mornings.

One input, label and panel triplet per section, stacked as an accordion by default. A container query switches to the tab row when the component is wider than 34rem, so it reacts to its own width, not the viewport. Drag the corner handle on a wide screen to watch it switch.

View code
.tb-ca {
  container-type: inline-size;
  width: min(100%, 680px);
  min-width: min(100%, 260px);
  max-width: 100%;
  overflow: hidden;
  resize: horizontal;
  border-radius: 18px;
  background: #fff;
  color: #1e2a3a;
  box-shadow: 0 14px 34px rgba(30, 42, 58, .1);
}
.tb-ca__tabs {
  position: relative;
  display: flex;
  flex-direction: column;
  padding: 8px;
}
.tb-ca__input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-ca__tab {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 48px;
  padding: 0 16px;
  border-radius: 12px;
  color: #3d4a5c;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .2s ease, color .2s ease;
}
.tb-ca__tab::after {
  content: "";
  width: 8px;
  height: 8px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-2px) rotate(45deg);
}
.tb-ca__tab:hover {
  background: #f1f4f8;
}
.tb-ca__input:checked + .tb-ca__tab {
  background: #e7eefb;
  color: #1747a6;
}
.tb-ca__input:checked + .tb-ca__tab::after {
  transform: translateY(2px) rotate(-135deg);
}
.tb-ca__input:focus-visible + .tb-ca__tab {
  outline: 2px solid #1747a6;
  outline-offset: 2px;
}
.tb-ca__panel {
  display: none;
  padding: 10px 16px 16px;
  color: #45536a;
  line-height: 1.6;
}
.tb-ca__panel p {
  margin: 0;
}
.tb-ca__input:checked + .tb-ca__tab + .tb-ca__panel {
  display: block;
}
@container (min-width: 34rem) {
  .tb-ca__tabs {
    flex-direction: row;
    flex-wrap: wrap;
    gap: 4px;
  }
  .tb-ca__tab::after {
    display: none;
  }
  .tb-ca__panel {
    order: 1;
    width: 100%;
    min-height: 90px;
    margin-top: 8px;
    padding: 18px 16px;
    border-top: 1px solid #e3e8ef;
  }
}
@media (prefers-reduced-motion: no-preference) {
  .tb-ca__tab::after {
    transition: transform .2s ease;
  }
}

Scrolling tab bar with snap and edge cues

  • scroll-snap
  • scroll-driven animation
  • inset box-shadow

142 articles across every topic, newest first.

24 articles on grid, flexbox and container queries.

19 articles on focus, contrast and screen reader testing.

17 articles on loading, layout shift and interaction speed.

15 articles on variable fonts, rhythm and type scales.

12 articles on transitions, easing and reduced motion.

21 articles on build tools, linters and editors.

9 articles on portfolios, interviews and pay.

25 conversations with designers and front-end developers.

Nine topics in one row that scrolls sideways and snaps to each label. The edge cues are inset box-shadows on two overlay strips, faded in and out by a scroll-driven animation tied to the bar, so a cue only shows on the side that still has tabs. Without scroll-driven animations, or with reduced motion, a static cue stays on the right.

View code
.tb-sc {
  container-type: inline-size;
  width: min(100%, 560px);
  padding: 16px;
  box-sizing: border-box;
  border-radius: 18px;
  background: #fff;
  color: #1d1f24;
  box-shadow: 0 14px 34px rgba(29, 31, 36, .08);
  timeline-scope: --tb-sc;
}
.tb-sc__wrap {
  position: relative;
}
.tb-sc__wrap::before,
.tb-sc__wrap::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 8px;
  z-index: 1;
  width: 28px;
  pointer-events: none;
}
.tb-sc__wrap::before {
  left: 0;
  box-shadow: inset 16px 0 12px -12px rgba(29, 31, 36, .3);
  opacity: 0;
}
.tb-sc__wrap::after {
  right: 0;
  box-shadow: inset -16px 0 12px -12px rgba(29, 31, 36, .3);
}
.tb-sc__bar {
  display: flex;
  gap: 8px;
  padding-bottom: 8px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: 4px;
  scrollbar-width: thin;
  overscroll-behavior-x: contain;
  scroll-timeline: --tb-sc inline;
}
.tb-sc__tab {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  min-height: 40px;
  padding: 0 16px;
  border: 1px solid #d6d9e0;
  border-radius: 999px;
  color: #444955;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  scroll-snap-align: start;
  transition: background-color .2s ease, color .2s ease, border-color .2s ease;
}
.tb-sc__tab input {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}
.tb-sc__tab:hover {
  border-color: #9ca2ae;
  color: #1d1f24;
}
.tb-sc__tab:has(input:checked) {
  border-color: #1d1f24;
  background: #1d1f24;
  color: #fff;
}
.tb-sc__tab:has(input:focus-visible) {
  outline: 2px solid #e0531f;
  outline-offset: 2px;
}
.tb-sc__panels {
  margin-top: 14px;
  padding: 16px;
  border-radius: 12px;
  background: #f4f5f7;
}
.tb-sc__panel {
  display: none;
  margin: 0;
  color: #444955;
  line-height: 1.5;
}
.tb-sc__panel b {
  color: #1d1f24;
}
.tb-sc__panel:first-child,
.tb-sc:has(#tb-sc-2:checked) #tb-sc-p2,
.tb-sc:has(#tb-sc-3:checked) #tb-sc-p3,
.tb-sc:has(#tb-sc-4:checked) #tb-sc-p4,
.tb-sc:has(#tb-sc-5:checked) #tb-sc-p5,
.tb-sc:has(#tb-sc-6:checked) #tb-sc-p6,
.tb-sc:has(#tb-sc-7:checked) #tb-sc-p7,
.tb-sc:has(#tb-sc-8:checked) #tb-sc-p8,
.tb-sc:has(#tb-sc-9:checked) #tb-sc-p9 {
  display: block;
}
.tb-sc:has(.tb-sc__tab:not(:first-child) input:checked) #tb-sc-p1 {
  display: none;
}
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .tb-sc__wrap::before {
      animation: tb-sc-left linear both;
      animation-timeline: --tb-sc;
    }
    .tb-sc__wrap::after {
      animation: tb-sc-right linear both;
      animation-timeline: --tb-sc;
    }
  }
}
@keyframes tb-sc-left {
  0% {
    opacity: 0;
  }
  8%,
  100% {
    opacity: 1;
  }
}
@keyframes tb-sc-right {
  0%,
  92% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

Icon tabs in an app bottom bar

  • inline SVG icons
  • grid rows
  • :has() screens

Explore near Ardmore

A hiker with a large backpack standing on a rock above a hazy mountain valley

Saddle Lake traverse11 km, hard, 4.9 rating

Granite cliffs above a pine forest and a calm river with a sandy bank

River meadow walk5 km, easy, 4.7 rating

Saved trails

  • Old quarry ridge8 km, moderate
  • Twin falls circuit13 km, hard
  • Heron marsh boardwalk3 km, easy

Your trips

  • Coast weekend14 to 16 November, 3 trails
  • Winter campJanuary, gear list 80% packed

Noor Haddad

  • 312 kmthis year
  • 41trails
  • 8,940 mclimbed

Recent

  • Saddle Lake traverseSunday, 11.2 km in 4 h 05
  • River meadow walkWednesday, 5.1 km in 1 h 20
  • Old quarry ridge12 October, 8.4 km in 2 h 50

The bottom navigation of a trail app, built from four radio labels with an icon and a short visible word, so every tab keeps a text name. Each screen scrolls on its own above the bar, and the checked tab gets a filled pill behind its icon, the cue most mobile design systems use.

View code
.tb-app {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-rows: 1fr auto;
  background: #f6f4ee;
  color: #1f2a22;
}
.tb-app__screens {
  min-height: 0;
  overflow-y: auto;
}
.tb-app__screen {
  display: none;
  padding: 26px 18px 18px;
}
.tb-app__screen:first-child,
.tb-app:has(#tb-app-2:checked) #tb-app-s2,
.tb-app:has(#tb-app-3:checked) #tb-app-s3,
.tb-app:has(#tb-app-4:checked) #tb-app-s4 {
  display: block;
}
.tb-app:has(.tb-app__tab:not(:first-child) input:checked) #tb-app-s1 {
  display: none;
}
.tb-app__h {
  margin: 0 0 14px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  font-weight: 600;
}
.tb-app__sub {
  margin: 22px 0 10px;
  color: #58645b;
  font-size: 13px;
  font-weight: 700;
}
.tb-app__card {
  overflow: hidden;
  margin-bottom: 14px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 6px 18px rgba(31, 42, 34, .08);
}
.tb-app__card img {
  display: block;
  width: 100%;
  height: 150px;
  object-fit: cover;
}
.tb-app__card p {
  display: grid;
  margin: 0;
  padding: 12px 14px;
}
.tb-app__card span,
.tb-app__list span,
.tb-app__stats span {
  color: #58645b;
  font-size: 13px;
}
.tb-app__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.tb-app__list li {
  display: grid;
  gap: 2px;
  margin-bottom: 10px;
  padding: 14px 16px;
  border-radius: 14px;
  background: #fff;
}
.tb-app__stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tb-app__stats li {
  display: grid;
  padding: 14px 10px;
  border-radius: 14px;
  background: #fff;
  text-align: center;
}
.tb-app__nav {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  padding: 8px 8px 14px;
  border-top: 1px solid #e2ded2;
  background: #fff;
}
.tb-app__tab {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 4px;
  min-height: 52px;
  padding-top: 4px;
  color: #58645b;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
}
.tb-app__tab input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.tb-app__tab svg {
  box-sizing: content-box;
  width: 22px;
  height: 22px;
  padding: 4px 18px;
  border-radius: 999px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: background-color .2s ease;
}
.tb-app__tab:has(input:checked) {
  color: #1f4d2e;
}
.tb-app__tab:has(input:checked) svg {
  background: #d4ead6;
}
.tb-app__tab:has(input:focus-visible) svg {
  outline: 2px solid #1f4d2e;
  outline-offset: 2px;
}

Which CSS tabs method should you use?

Radio inputs cover plain content switching. :target is the one to pick when each panel needs its own URL. A details group gives you an exclusive accordion dressed up as tabs. Once the tabs have to follow the full WAI-ARIA tabs pattern, you need JavaScript and ARIA.

None of the first three needs a click handler written in JavaScript, since the browser does the bookkeeping. The fourth keeps CSS for the visuals and adds a short script that updates aria-selected and moves focus.

MethodState held byDeep linkKeyboard behavior
Radio inputChecked radio (:checked)NoArrow keys, while inputs stay focusable
:targetURL fragmentYesTab stop per link, Enter
details groupOpen attribute, grouped by nameNoTab stop per summary, Space or Enter
CSS plus JavaScriptaria-selectedOnly if scriptedArrow keys, optional Home and End

If readers need to bookmark or share one panel, use :target.

If a screen reader has to announce the widget as tabs, write the script.

Anything else, pricing toggles and product spec panels included, is a job for radio inputs. That’s also the method most tutorials teach.

How to build CSS tabs with radio inputs

The whole method rests on one rule. A checked radio input shows the panel that follows its label, and the steps below just set that up.

  1. Add one radio input per tab inside a wrapper, give every input the same name, and put the checked attribute on the first.
  2. Follow each input with a label whose for value matches the input id.
  3. Follow each label with its panel, a div with the class panel.
  4. Set the wrapper to display: flex with flex-wrap: wrap, and give each panel order: 1 and width: 100% so the labels form a row above the panels.
  5. Hide every panel with display: none, then show the active one with a sibling selector.
  6. Hide the radio inputs visually and style the checked label as the active tab.

There’s no click handler to write or debug, and the tabs still work with scripts blocked. Panels stay in the DOM, so switching has no loading delay.

The downsides are real, though. The selected tab isn’t stored in the URL, so a shared link always opens the default tab. Assistive technology announces radio buttons, not tabs. Panels must follow their labels in source order, and the panel count is fixed in the markup.

Markup structure

Sibling order is the rule that cannot bend. Each input comes before its label, and each label before its panel.

<div class="tabs">
  <input type="radio" name="tabs" id="tab-1" checked>
  <label class="tab-label" for="tab-1">Specs</label>
  <div class="panel">...</div>

  <input type="radio" name="tabs" id="tab-2">
  <label class="tab-label" for="tab-2">Reviews</label>
  <div class="panel">...</div>
</div>

Move a panel before its label and the selector no longer reaches it, so that tab opens an empty area.

Showing the active panel

The next-sibling combinator (+) ties a panel to the checked input and label placed directly before it, as in input:checked + .tab-label + .panel. The subsequent-sibling combinator (~) reaches any later sibling, which suits layouts with all inputs first and all panels last, though it needs one rule per input, such as #tab-1:checked ~ #panel-1.

.tabs { position: relative; display: flex; flex-wrap: wrap; }
.tabs input { position: absolute; opacity: 0; }
.panel { display: none; order: 1; width: 100%; }
.tabs input:checked + .tab-label { border-bottom: 3px solid currentColor; }
.tabs input:checked + .tab-label + .panel { display: block; }

Hide the inputs with position: absolute and opacity: 0, never with display: none. An element set to display: none leaves the tab order, so keyboard users lose the radio group.

Simplifying selectors with :has()

MDN Web Docs lists :has() as Baseline widely available, and as available across browsers since December 2023 (checked October 2026).

With it, the wrapper reacts to the checked input directly: .tabs:has(#tab-2:checked) #panel-2 { display: block; }. Inputs and labels no longer need to sit before the panels, so the tab bar and the panels can live in separate containers.

The failure mode is all-or-nothing. Per the same MDN page, a browser without :has() drops the entire selector block, so every panel stays hidden.

If the audience includes older browsers, stay with the sibling selectors, which carry no such gap. Anchor :has() to the .tabs wrapper rather than body, because MDN’s performance guidance says the browser re-checks the condition on every DOM change inside the anchor.

How do :target and details elements work as CSS tabs?

With :target, the URL hash decides which panel shows. Details elements work differently, since the name attribute keeps only one of them open at a time. Neither method uses a form control, so neither gets the arrow-key behavior of a radio group for free.

Tabs with :target

Each tab is a link to a panel id, such as href="#specs", and the panel shows only while it is the document’s target element.

The markup is a row of anchor links followed by panels whose ids match the hash values. Panels start at display: none, and .panel:target sets display: block. Without a hash nothing is targeted, so the first panel needs its own fallback rule.

As of October 2026, MDN Web Docs lists :target as Baseline widely available, and as available across browsers since July 2015. The browser sets the target element at document load and on history.back(), history.forward() and history.go() calls, but not on pushState() or replaceState().

Because the hash lives in the URL, every tab is deep-linkable and the Back button moves between earlier tabs.

One documented limit: :target does not work inside a web component, because the shadow root does not pass the target element down to the shadow tree.

Every panel gets its own URL and Back works. There’s no form control in the markup either. The catch is that each click jumps the page toward the target panel, and every tab adds a history entry that Back has to step through.

Tabs with details elements

The web-platform-dx web-features data lists the details name attribute as Baseline newly available since 3 September 2024. Chrome 120 and Safari 17.2 shipped it in December 2023, and Firefox 130 completed the set in September 2024.

Give every details element the same name and the browser closes the open one when another opens. The elements do not have to be siblings, because the name groups them, not the DOM order.

MDN’s styling example gives a details element a look similar to a tabbed interface, with the summary acting as the tab. Placing all summaries in one row above a shared panel still takes extra layout CSS, because each panel stays inside its own details element.

If several grouped elements carry open, only the first in source order renders open. Clicking the open summary closes it, so a group can end up with no panel visible.

Engines without name support can use a short polyfill that listens for the toggle event and closes the other open elements with the same name. Where the toggle event does not fire, the polyfill does nothing, which Chrome for Developers calls acceptable progressive enhancement.

Because this method is an exclusive accordion underneath, CSS accordion techniques for styling and animation carry over.

On the plus side, opening is exclusive without a script, and each summary is focusable and toggles with Space or Enter. On the minus side, MDN lists the implicit role as group and permits no role attribute, so the tab role cannot be added. A tab set should always show one panel, and this group can show none. The tab-bar layout needs extra CSS too.

Are CSS tabs accessible to keyboard and screen reader users?

Only partly. CSS selectors show and hide panels, but they cannot write aria-selected, so a CSS-only tab set meets part of the keyboard model and misses the state half of the WAI-ARIA tabs pattern.

The W3C ARIA Authoring Practices Guide (APG) defines the target behavior. Its tabs pattern is built from the tablist, tab and tabpanel ARIA roles.

Roles and states

The tablist is the container for the tabs, labeled with aria-label or aria-labelledby. Each tab points to its panel through aria-controls, and the active tab has aria-selected set to true while every other tab has false. Each tabpanel points back to its tab through aria-labelledby. On a vertical tablist, set aria-orientation to vertical, since horizontal is the default.

Roles and the aria-controls and aria-labelledby references are static, so markup can carry them wherever the element permits a role. The aria-selected state changes on every activation, and no CSS selector can change an attribute.

In the radio method the input keeps its radio role, so assistive technology announces radio buttons and not tabs. The details method has a harder limit, because the MDN reference permits no role attribute on details.

Keyboard interaction by method

The APG expects fairly specific behavior. Tab moves focus into the active tab, and the next Tab press leaves the tablist for the panel. Left and Right Arrow move between tabs in a horizontal list, Up and Down in a vertical one. Space or Enter activates a tab that did not activate on focus. Home and End jump to the first and last tab, which is optional.

The CSS methods deliver less. With radio inputs, Tab lands on the checked radio, and arrow keys move focus and selection together, but only while the input stays focusable. :target gives each link its own Tab stop, and arrow keys scroll the page as usual. Details elements are similar, with each summary as a Tab stop that Space or Enter toggles.

Read side by side, the radio method lands closest to the APG, because arrow keys move focus and select in one step, which matches the automatic-activation variant. The APG recommends that variant when panels display without noticeable latency, and CSS panels already sit in the DOM, so no latency exists.

Beyond the missing tab role, one gap remains: Tab leaves the radio group for the next focusable element, which is the panel only when the panel carries tabindex="0". The APG asks for that attribute whenever a panel holds no focusable content.

The invisible input cannot show focus, so draw the ring on its label: .tabs input:focus-visible + .tab-label { outline: 2px solid; }. The :focus-visible pseudo-class matches when the browser decides focus should be visible, which is the case for keyboard use.

How to style the active tab and animate the indicator

Active styling should come from the same selector that shows the panel. Animate the indicator with transform on a pseudo-element and fade the panel in with opacity. Keep the selected, hover and focus states visually distinct so none reads as another.

Replace the border rule from the radio build with an underline that scales out from the center. A transform animation stays smooth because it does not trigger layout.

.tab-label { position: relative; }
.tab-label::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 3px;
  background: currentColor;
  transform: scaleX(0);
  transition: transform 0.2s ease;
}
.tabs input:checked + .tab-label::after { transform: scaleX(1); }

With transforms alone, a single bar that slides between tabs works only when the tabs have equal widths, because a translateX percentage is relative to the bar’s own width.

Tune duration and easing, and keep the indicator short enough that it never delays the panel.

Fading the panel runs into a limit: display: none cannot transition, because display is a discrete property. Two features fix it. The value transition-behavior: allow-discrete lets display take part in a transition, and @starting-style supplies the before-open state (web.dev).

.tabs input:checked + .tab-label + .panel {
  display: block;
  opacity: 1;
  transition: opacity 0.25s ease, display 0.25s allow-discrete;
}
@starting-style {
  .tabs input:checked + .tab-label + .panel { opacity: 0; }
}

Put the transition on the checked rule only. A fade-out on the leaving panel would keep two panels in the flow for the length of the transition and shift the page height.

Engines without the two features skip the effect and show the panel at once, which is an acceptable fallback.

For the states themselves, selected gets a thicker border and a heavier weight, so the cue survives high contrast settings that drop color (the APG tabs example makes the active tab taller, with a thicker border). Hover gets a background tint and nothing more, lighter than the selected cue. Focus gets a ring separated from the selection border by a small gap, so the two stay separately visible.

MDN Web Docs notes that motion-based animation can trigger discomfort for people with vestibular motion disorders. Switch the transitions off for anyone who asks for reduced motion.

@media (prefers-reduced-motion: reduce) {
  .tab-label::after,
  .tabs input:checked + .tab-label + .panel { transition: none; }
}

How to make CSS tabs responsive

Pick the width where the longest label stops fitting, then either stack the tabs into an accordion or keep one row that scrolls sideways. Panel count and panel length decide which one fits.

Nielsen Norman Group’s guidance is that tabs suit a few long sections, while accordions fit many short ones.

LayoutFitsExtra CSS cost
Stacked accordionMany short panelsReset order, switch to a column
Scrollable tab barFew long panelsLabels in their own container
Details group left stackedMany short panels, exclusive openingDrop the row layout

The radio markup makes the stacked layout cheap, because each panel already follows its own label. Set the breakpoint with a media query tied to the width where the longest label stops fitting, not to a device size.

.tabs { display: flex; flex-direction: column; }

@media (min-width: 40em) {
  .tabs { flex-direction: row; flex-wrap: wrap; }
  .panel { order: 1; width: 100%; }
}

Written this way, the stacked layout is the base and the tab row is the enhancement, which is the mobile-first order.

A scrolling bar needs the labels in a container of their own, with overflow-x: auto on it, so it cannot use the input-label-panel triplet. Use the :has() layout from the radio section instead, where the tab bar and the panels live in separate containers.

Size container queries respond to the space a component receives, not to the viewport. Put container-type: inline-size on the parent of .tabs, because a container cannot style itself, then swap @media for @container (min-width: 30em) in the rule above.

When labels shrink to fit a narrow row, each one still needs a pointer target of at least 24 by 24 CSS pixels, or enough spacing that a 24-pixel circle centered on it touches no other target (W3C, WCAG 2.2, success criterion 2.5.8, level AA).

Where CSS-only tabs stop working

CSS-only tabs stop working when the tab set needs behavior that static markup cannot hold, such as panels loaded on demand or a tab count that changes at runtime. Manual activation is another case.

CSS cannot fetch content, and the APG recommends automatic activation only when panels display without noticeable latency, which typically means preloaded content. On-demand panels therefore need a script and manual activation.

The radio method selects on every arrow press, so a keyboard user cannot pass over a tab without opening it.

Tabs that open or close at runtime are out too. The APG lists the Delete key for closable tabs as optional behavior, and only a script can remove a tab together with its panel.

Find in page is a smaller annoyance. Text inside a panel set to display: none is not found, while details elements that contain a find-in-page match now open automatically in Chrome 97, Firefox 148 and Safari 26.2 and later (MDN browser-compat-data), a fix web.dev’s What’s new in web review from Google I/O 2025 listed among the Interop 2025 details work.

Every feature used so far has its own support history, so cross-browser compatibility becomes a per-feature question. The fallback differs too. Engines without the animation features show the panel at once, which costs nothing. For the focus ring, a common fallback repeats the ring on :focus inside @supports not selector(:focus-visible).

As of October 2026, the details element has been available across browsers since January 2020 (MDN Web Docs). Size container queries became Baseline newly available in February 2023 and Widely available in August 2025 (web.dev). @starting-style and transition-behavior: allow-discrete became Baseline newly available with Firefox 129 in August 2024 (web.dev), though Firefox still does not transition display itself, so the fade-out half stays Chrome and Safari only (MDN browser-compat-data). :focus-visible has been available across browsers since March 2022 (MDN Web Docs), and its Widely available label dates from 14 September 2024 (web-platform-dx web-features).

The two dates for :focus-visible look like a conflict, but they measure different events. MDN dates the point where every core browser shipped the feature, and web-features dates the Widely available label, which Baseline grants 30 months later (web.dev).

The :has() date quoted earlier follows the same pattern: it marks interoperability, and the Widely available label arrived 30 months later, in June 2026.

Add the script when any failure above applies, or when the design needs aria-selected and a roving tabindex. The APG’s automatic-activation example sets tabindex="-1" on every unselected tab, so only the selected tab sits in the Tab sequence.

A script writes aria-selected and the roving tabindex that the CSS methods cannot. It also supports manual activation, closable tabs and panels fetched on demand.

The cost is more code to ship, test and maintain. The APG labels its own example as not intended for production and warns of support gaps in some browser and assistive technology pairs, especially on mobile and touch devices. And half-finished ARIA does more harm than none.

Skip role="tab" on a CSS-only set. The APG puts it bluntly: “No ARIA is better than Bad ARIA.”

Tab roles without the matching keyboard model promise behavior the widget does not deliver.

CSS Tabs FAQ

Can CSS tabs be vertical or styled as pills?

Yes, and both are layout and styling changes only. For vertical tabs, place the labels in a left column with grid or flex and keep the panels beside them. Pills need just a border-radius and a background on the checked label.

How do you add icons to CSS tabs?

Put an inline SVG inside the label, next to the text. For icon-only tabs, keep the text in the label and hide it visually, so the tab still has an accessible name.

Do CSS tabs keep the selected tab after a page reload?

Only the :target method does so reliably, because the URL fragment survives a reload.

Radio state is form state, which browsers restore inconsistently, and the open state of details elements is not stored in the URL, so a shared link always opens the default tab.

Will search engines index content in hidden tab panels?

Yes, when the panels are in the HTML source. A crawler that reads the markup sees every panel, even those hidden with display: none.

Panels fetched by a script after a click do not exist until that click, so avoid that pattern for content that must rank.

Does Bootstrap or Tailwind need JavaScript for tabs?

Bootstrap does. Its tab component runs on its own JavaScript plugin and data attributes. Tailwind ships no tab component, so you build CSS tabs yourself, typically with the peer and peer-checked utilities on radio inputs.

Why does the page jump when panels have different heights?

The container resizes to fit whichever panel is visible, because hidden panels are removed from layout. To stop the jump, stack all panels in one grid cell and hide inactive ones with visibility: hidden, so the container always matches the tallest panel.

How to Test CSS Tabs Before Shipping

Start with the keyboard alone. A screen reader announces whatever receives focus, so a broken focus path corrupts every result after it.

Run the screen reader pass second, and leave the narrow viewport for last, because layout changes leave the semantics untouched.

Verified on 13 October 2026, the details name attribute reaches Widely available status in March 2027, 30 months after its September 2024 Baseline date.

From that month, a details-based tab set carries no support caveat for core browsers. Recheck the support figures at that date before changing the recommended method.