32 CSS Tab Designs01 / 32

CSS + JSMIT licensed

Ink Slider

Solid ink-violet bar slides horizontally beneath the active tab. JS measures offsetLeft + offsetWidth so the bar matches each tab's exact width — no fixed-width assumption — and re-aligns on viewport resize.

Published

Live Demo
Try it

The code

<div class="tt21">
  <nav class="tt21n">
    <span class="tt21bar" aria-hidden="true"></span>
    <button class="tt21b active" data-t>Overview</button>
    <button class="tt21b" data-t>Pricing</button>
    <button class="tt21b" data-t>Reviews</button>
    <button class="tt21b" data-t>Support</button>
  </nav>
</div>
.tt21 {
  background: #faf8f3;
  padding: 32px 18px 0;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  box-sizing: border-box;
  width: 100%;
}

.tt21n {
  position: relative;
  display: flex;
  border-bottom: 1px solid rgba(35, 28, 72, 0.12);
  min-width: 0;
}

.tt21bar {
  position: absolute;
  bottom: -1px;
  left: 0;
  height: 3px;
  width: 0;
  background: #231c48;
  border-radius: 2px 2px 0 0;
  transition: left 0.45s cubic-bezier(0.65, 0, 0.35, 1), width 0.45s cubic-bezier(0.65, 0, 0.35, 1);
  pointer-events: none;
}

.tt21b {
  flex: 1;
  min-width: 0;
  padding: 12px 14px 14px;
  border: 0;
  background: transparent;
  font: 600 13px/1 ui-sans-serif, system-ui;
  color: rgba(35, 28, 72, 0.55);
  cursor: pointer;
  transition: color 0.25s;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tt21b:hover {
  color: rgba(35, 28, 72, 0.85);
}

.tt21b.active {
  color: #231c48;
}

.tt21b:focus-visible {
  outline: 2px solid #231c48;
  outline-offset: -2px;
}

@media (prefers-reduced-motion: reduce) {
  .tt21bar {
    transition: none;
  }
}
/* Ink Slider — toggle .active and slide the ink bar to the active tab.
   Re-positions on viewport resize so the bar stays locked to its button. */
(function () {
  var nav = document.querySelector(".tt21n");
  if (!nav) return;
  var btns = nav.querySelectorAll("[data-t]");
  var bar = nav.querySelector(".tt21bar");
  var current = null;

  function reposition() {
    if (!current || !bar) return;
    bar.style.left = current.offsetLeft + "px";
    bar.style.width = current.offsetWidth + "px";
  }
  function activate(btn) {
    current = btn;
    btns.forEach(function (b) {
      b.classList.toggle("active", b === btn);
    });
    reposition();
  }
  btns.forEach(function (b) {
    b.addEventListener("click", function () {
      activate(b);
    });
  });
  window.addEventListener("resize", reposition);
  var initial = nav.querySelector("[data-t].active") || btns[0];
  if (initial) activate(initial);
})();
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: Ink Slider
Source: https://codefronts.com/navigation/css-tabs/ink-slider/

Solid ink-violet bar slides horizontally beneath the active tab. JS measures offsetLeft + offsetWidth so the bar matches each tab's exact width — no fixed-width assumption — and re-aligns on viewport resize.
## HTML
```html
<div class="tt21">
  <nav class="tt21n">
    <span class="tt21bar" aria-hidden="true"></span>
    <button class="tt21b active" data-t>Overview</button>
    <button class="tt21b" data-t>Pricing</button>
    <button class="tt21b" data-t>Reviews</button>
    <button class="tt21b" data-t>Support</button>
  </nav>
</div>
```
## CSS
```css
.tt21 {
  background: #faf8f3;
  padding: 32px 18px 0;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  box-sizing: border-box;
  width: 100%;
}

.tt21n {
  position: relative;
  display: flex;
  border-bottom: 1px solid rgba(35, 28, 72, 0.12);
  min-width: 0;
}

.tt21bar {
  position: absolute;
  bottom: -1px;
  left: 0;
  height: 3px;
  width: 0;
  background: #231c48;
  border-radius: 2px 2px 0 0;
  transition: left 0.45s cubic-bezier(0.65, 0, 0.35, 1), width 0.45s cubic-bezier(0.65, 0, 0.35, 1);
  pointer-events: none;
}

.tt21b {
  flex: 1;
  min-width: 0;
  padding: 12px 14px 14px;
  border: 0;
  background: transparent;
  font: 600 13px/1 ui-sans-serif, system-ui;
  color: rgba(35, 28, 72, 0.55);
  cursor: pointer;
  transition: color 0.25s;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.tt21b:hover {
  color: rgba(35, 28, 72, 0.85);
}

.tt21b.active {
  color: #231c48;
}

.tt21b:focus-visible {
  outline: 2px solid #231c48;
  outline-offset: -2px;
}

@media (prefers-reduced-motion: reduce) {
  .tt21bar {
    transition: none;
  }
}
```

## JavaScript
```js
/* Ink Slider — toggle .active and slide the ink bar to the active tab.
   Re-positions on viewport resize so the bar stays locked to its button. */
(function () {
  var nav = document.querySelector(".tt21n");
  if (!nav) return;
  var btns = nav.querySelectorAll("[data-t]");
  var bar = nav.querySelector(".tt21bar");
  var current = null;

  function reposition() {
    if (!current || !bar) return;
    bar.style.left = current.offsetLeft + "px";
    bar.style.width = current.offsetWidth + "px";
  }
  function activate(btn) {
    current = btn;
    btns.forEach(function (b) {
      b.classList.toggle("active", b === btn);
    });
    reposition();
  }
  btns.forEach(function (b) {
    b.addEventListener("click", function () {
      activate(b);
    });
  });
  window.addEventListener("resize", reposition);
  var initial = nav.querySelector("[data-t].active") || btns[0];
  if (initial) activate(initial);
})();
```

How this works

The .tt21 shell paints a warm off-white canvas at #faf8f3 and hosts a flex .tt21n nav with position: relative, a hairline 1px solid rgba(35, 28, 72, 0.12) baseline, and equal-width .tt21b buttons using flex: 1. The .tt21bar element is absolutely positioned at bottom: -1px with a solid #231c48 ink fill, a 3px height, and a subtle 2px 2px 0 0 top-corner radius that sits over the baseline hairline. Hand-coded vanilla JS reads current.offsetLeft and current.offsetWidth, writes them to inline styles, and a paired cubic-bezier(0.65, 0, 0.35, 1) transition handles the slide.

The palette leans editorial: ink violet #231c48 against paper cream #faf8f3, with resting labels at rgba(35, 28, 72, 0.55) and hover at 0.85 so the active label reads darkest without any weight change. Typography is a copy-paste system stack, ui-sans-serif, system-ui, held at 600 13px/1 so a four-tab row stays honest at narrow widths thanks to white-space: nowrap plus text-overflow: ellipsis.

Semantic HTML uses real button elements, so keyboard tab, Enter, and Space work with zero extra scripting. A :focus-visible ring at 2px solid #231c48 with -2px inset offset satisfies WCAG 2.4.7 without doubling as a click ring. The measured slide runs on transform-adjacent inline styles rather than layout thrash, keeping INP under 200ms on mid-tier Android, and @media (prefers-reduced-motion: reduce) nulls the transition per WCAG 2.3.3. Because the bar is aria-hidden, screen readers announce only the label — pair with role="tablist" when wiring real tabpanels.

Make it yours

  • Swap ink #231c48 for your brand token — recolor .tt21bar background, the .tt21b.active color, and the :focus-visible outline together so the register stays coherent.
  • Change the type ramp by editing the font shorthand on .tt21b. A 500 14px/1 Inter or a 600 12px/1 uppercase treatment with letter-spacing: 0.08em both suit the ink slider aesthetic.
  • Shape the bar. Bump .tt21bar height to 4px and drop the radius to 0 for a hard broadsheet rule, or raise it to 999px for a rounded capsule feel.
  • Dark mode: flip .tt21 background to #0e0b1a, set label color to rgba(255, 255, 255, 0.65), active to #ffffff, and the bar to #a78bfa so the ink register keeps its contrast story.
  • Tailwind arbitrary values: bg-[#faf8f3] on the wrapper, bg-[#231c48] on the bar, text-[rgb(35_28_72_/_0.55)] on inactive, and text-[#231c48] on active — the JS positioning logic ports unchanged.

Gotchas — read before shipping

  • Resting label color rgba(35, 28, 72, 0.55) on #faf8f3 lands near WCAG 1.4.3 minimums for 13px text. If localisation lengthens labels or you drop weight below 600, lift resting opacity to 0.7 or verify with a contrast checker.
  • The JS binds click only. For WCAG 2.1.1 you already get keyboard activation from real buttons, but if you migrate to the ARIA tabs pattern add role="tablist", role="tab", aria-selected, and Left/Right arrow-key navigation with tabindex roving.
  • React StrictMode double-invokes effects in dev, so binding the resize listener inside useEffect without a cleanup will fire the reposition twice. Return a cleanup that removes the listener and clear current to keep the bar honest across remounts.
  • Because the bar reads offsetLeft and offsetWidth synchronously, mounting inside a display:none parent yields zeros and the bar stays hidden. Trigger a reposition on the parent's transitionend or after your tab-panel IntersectionObserver reports visible.
  • Next.js App Router streams the shell before hydration; the bar renders at width: 0 until JS runs, so there is no CLS but the active label sits without a rule for a beat. Ship an SSR-safe initial inline style hint if that flash bothers you.
  • Safari 15.4 gained :focus-visible, so older iOS may show the outline on tap. If your analytics still see meaningful iOS 15.3 traffic, add outline: none under @supports not selector(:focus-visible) or accept the small cosmetic hit for WCAG 2.4.7 compliance.

Browser support

ChromeSafariFirefoxEdge
90+15.4+107+90+

Uses offsetLeft/offsetWidth measurement and cubic-bezier transitions supported everywhere; :focus-visible needs Safari 15.4.

Techniques used in this demo

Search CodeFronts

Loading…