32 CSS Tab Designs24 / 32

Pure CSSMIT licensed

Hairline Frame

Hairline-framed tabs in warm-white stock. Active tab's top edge thickens to 4px deep-navy AND four corner dots materialize at the frame's extremes — a Bodoni stroke-contrast move with editorial restraint.

Published Updated

Live Demo
Try it

The code

<div class="tt12">
  <nav class="tt12n">
    <input type="radio" name="tt12" id="tt12-r1" checked />
    <label class="tt12b" for="tt12-r1">
      Drafts
      <span class="tt12tk tt12tk-1" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-2" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-3" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-4" aria-hidden="true"> </span>
    </label>
    <input type="radio" name="tt12" id="tt12-r2" />
    <label class="tt12b" for="tt12-r2">
      Proofs
      <span class="tt12tk tt12tk-1" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-2" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-3" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-4" aria-hidden="true"> </span>
    </label>
    <input type="radio" name="tt12" id="tt12-r3" />
    <label class="tt12b" for="tt12-r3">
      Final
      <span class="tt12tk tt12tk-1" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-2" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-3" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-4" aria-hidden="true"> </span>
    </label>
  </nav>
  <div class="tt12p">Working files in progress.</div>
  <div class="tt12p">Galleys ready for review.</div>
  <div class="tt12p">Approved and locked.</div>
</div>
.tt12 {
  min-height: 100vh;
  background: #f5f1ea;
  padding: 26px 22px 22px;
  font-family: ui-serif, Georgia, serif;
  width: 100%;
}

.tt12n {
  display: flex;
  gap: 14px;
}

.tt12n input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.tt12b {
  position: relative;
  padding: 14px 24px 11px;
  border: 0.5px solid #1a2842;
  border-top: 4px solid transparent;
  background: transparent;
  font: 600 13px/1 ui-serif, Georgia, serif;
  letter-spacing: 0.06em;
  color: rgba(26, 40, 66, 0.5);
  cursor: pointer;
  transition: color 0.3s, border-top-color 0.3s, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.tt12b:hover {
  color: rgba(26, 40, 66, 0.85);
  transform: translateY(-1px);
}

.tt12n input:checked + .tt12b {
  color: #1a2842;
  border-top-color: #1a2842;
}

.tt12tk {
  position: absolute;
  width: 7px;
  height: 7px;
  background: #1a2842;
  border-radius: 50%;
  opacity: 0;
  transition: opacity 0.25s 0.1s, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s;
}

.tt12tk-1 {
  top: -3.5px;
  left: -3.5px;
  transform: scale(0);
}

.tt12tk-2 {
  top: -3.5px;
  right: -3.5px;
  transform: scale(0);
}

.tt12tk-3 {
  bottom: -3.5px;
  left: -3.5px;
  transform: scale(0);
}

.tt12tk-4 {
  bottom: -3.5px;
  right: -3.5px;
  transform: scale(0);
}

.tt12n input:checked + .tt12b .tt12tk {
  opacity: 1;
  transform: scale(1);
}

.tt12p {
  display: none;
  padding-top: 16px;
  font: italic 12px/1.6 ui-serif, Georgia;
  color: #3a3a42;
}

.tt12:has(#tt12-r1:checked) .tt12p:nth-of-type(1),
.tt12:has(#tt12-r2:checked) .tt12p:nth-of-type(2),
.tt12:has(#tt12-r3:checked) .tt12p:nth-of-type(3) {
  display: block;
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Tab Design from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Hairline Frame
Source: https://codefronts.com/navigation/css-tabs/hairline-frame/

Hairline-framed tabs in warm-white stock. Active tab's top edge thickens to 4px deep-navy AND four corner dots materialize at the frame's extremes — a Bodoni stroke-contrast move with editorial restraint.
## HTML
```html
<div class="tt12">
  <nav class="tt12n">
    <input type="radio" name="tt12" id="tt12-r1" checked />
    <label class="tt12b" for="tt12-r1">
      Drafts
      <span class="tt12tk tt12tk-1" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-2" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-3" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-4" aria-hidden="true"> </span>
    </label>
    <input type="radio" name="tt12" id="tt12-r2" />
    <label class="tt12b" for="tt12-r2">
      Proofs
      <span class="tt12tk tt12tk-1" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-2" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-3" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-4" aria-hidden="true"> </span>
    </label>
    <input type="radio" name="tt12" id="tt12-r3" />
    <label class="tt12b" for="tt12-r3">
      Final
      <span class="tt12tk tt12tk-1" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-2" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-3" aria-hidden="true"> </span>
      <span class="tt12tk tt12tk-4" aria-hidden="true"> </span>
    </label>
  </nav>
  <div class="tt12p">Working files in progress.</div>
  <div class="tt12p">Galleys ready for review.</div>
  <div class="tt12p">Approved and locked.</div>
</div>
```
## CSS
```css
.tt12 {
  min-height: 100vh;
  background: #f5f1ea;
  padding: 26px 22px 22px;
  font-family: ui-serif, Georgia, serif;
  width: 100%;
}

.tt12n {
  display: flex;
  gap: 14px;
}

.tt12n input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.tt12b {
  position: relative;
  padding: 14px 24px 11px;
  border: 0.5px solid #1a2842;
  border-top: 4px solid transparent;
  background: transparent;
  font: 600 13px/1 ui-serif, Georgia, serif;
  letter-spacing: 0.06em;
  color: rgba(26, 40, 66, 0.5);
  cursor: pointer;
  transition: color 0.3s, border-top-color 0.3s, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.tt12b:hover {
  color: rgba(26, 40, 66, 0.85);
  transform: translateY(-1px);
}

.tt12n input:checked + .tt12b {
  color: #1a2842;
  border-top-color: #1a2842;
}

.tt12tk {
  position: absolute;
  width: 7px;
  height: 7px;
  background: #1a2842;
  border-radius: 50%;
  opacity: 0;
  transition: opacity 0.25s 0.1s, transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s;
}

.tt12tk-1 {
  top: -3.5px;
  left: -3.5px;
  transform: scale(0);
}

.tt12tk-2 {
  top: -3.5px;
  right: -3.5px;
  transform: scale(0);
}

.tt12tk-3 {
  bottom: -3.5px;
  left: -3.5px;
  transform: scale(0);
}

.tt12tk-4 {
  bottom: -3.5px;
  right: -3.5px;
  transform: scale(0);
}

.tt12n input:checked + .tt12b .tt12tk {
  opacity: 1;
  transform: scale(1);
}

.tt12p {
  display: none;
  padding-top: 16px;
  font: italic 12px/1.6 ui-serif, Georgia;
  color: #3a3a42;
}

.tt12:has(#tt12-r1:checked) .tt12p:nth-of-type(1),
.tt12:has(#tt12-r2:checked) .tt12p:nth-of-type(2),
.tt12:has(#tt12-r3:checked) .tt12p:nth-of-type(3) {
  display: block;
}
```

How this works

The .tt12 demo runs a warm-white stock at #f5f1ea with deep-navy ink at #1a2842, a register borrowed from Swiss editorial design and studios like Feixen. Every tab carries a 0.5px hairline border on all sides and a 4px transparent top border pre-reserved in the box model. The active state fills that top border to navy and pushes four 7px circular corner dots — .tt12tk-1 through .tt12tk-4 — out to the frame's extremes, echoing a Bodoni stroke-contrast move at editorial scale.

The pre-reserved top border is the trick. Because .tt12b already declares border-top: 4px solid transparent, filling it via input:checked + .tt12b { border-top-color:#1a2842 } does not shift layout on the sibling tabs — a common Swiss-modernist requirement that column measure stays fixed. The corner dots are absolutely positioned at -3.5px from each edge and start at opacity:0; transform:scale(0). On check, they cross-fade in 0.25s with a 0.1s delay and pop up with cubic-bezier(0.34, 1.56, 0.64, 1), so the frame corners assemble a fraction after the top edge inks.

Panel routing is the same :has() plus nth-of-type pattern used across the cluster. Type is ui-serif, Georgia, serif at 600 13px/1 with 0.06em letter-spacing — closer tracking than the monospaced siblings, appropriate for a serif register. Semantic radios, WCAG 2.1 focusable, prefers-reduced-motion respected by the short transitions, no dependencies, SSR-safe, copy-paste into any framework-agnostic project.

Make it yours

  • Change the accent from #1a2842 to any editorial ink — burgundy #5c1a2b, forest #1f3d2b. Update the border, checked border-top-color, dot background, and text colour together; they are the same value in the source and drift is easy.
  • Resize the corner dots by editing the 7px width/height plus the four -3.5px offsets. The offset is always half the dot size — keep the two linked or the dots detach from the frame corners and drift outward.
  • The 0.5px hairline is deliberate. On a 2x display it renders as a true half-pixel; on 1x devices it will resolve to 1px. If you need a heavier frame, jump to 1px — anything between 0.5 and 1 antialiases inconsistently across browsers.
  • The gap:14px between tabs preserves the hairlines as discrete rectangles rather than a merged bar. Reduce to 0 only if you also remove the side hairlines, or the two adjacent 0.5px strokes double to a visible 1px.
  • The overshoot easing on the dots is what gives the frame its editorial settle. Replace with ease-out if you want a stricter Swiss register, or keep the overshoot if the demo sits next to more playful typography.

Gotchas — read before shipping

  • The four .tt12tk spans must live inside the label, not outside — the input:checked + .tt12b .tt12tk selector needs the descendant relationship. Move them out and the dots never materialise.
  • The dots use aria-hidden="true" because they are pure decoration. Do not remove that attribute — screen readers would otherwise announce four empty spans per active tab, and WCAG 2.1 flags that as noise.
  • Because the top border is pre-reserved at 4px solid transparent, the label sits 4px lower than a naive border implementation. If you change padding to compensate, remember to also change the inactive state so it does not jump when activated.
  • The :has() panel routing needs Safari 15.4, Chrome 105, Firefox 121. Older Firefox ESR will show all three .tt12p captions at once — provide an @supports fallback that hides all but the first if that matters to your audience.
  • The corner dots have a 0.1s delay on both the opacity and transform transitions. If you shorten the base 0.3s tab transition, keep the dot delay proportional or the dots arrive before the top border has fully inked, which reads as a synchronisation bug.
  • The panel text sets font: italic 12px/1.6 ui-serif, Georgia with no trailing serif keyword. That is valid CSS but not what the linter expects — some formatters will fix it and break the shorthand. Keep the trailing family list intact or the browser drops the whole declaration.

Browser support

ChromeSafariFirefoxEdge
105+15.4+121+105+

:has() gates panel switching; the frame and dots themselves work back to Chrome 60. Hairline 0.5px borders behave differently between 1x and 2x displays — test on both.

Techniques used in this demo

Search CodeFronts

Loading…