32 CSS Tab Designs12 / 32

CSS + JSMIT licensed

Particle Burst

Each tab click spawns 8 small color sparks that fly outward and fade. JS positions each particle at the click point; CSS animates trajectory + fade. The active tab is also highlighted with a coral underline.

Published

Live Demo
Try it

The code

<div class="tt32">
  <nav class="tt32n">
    <span class="tt32line" aria-hidden="true"></span>
    <button class="tt32b active" data-t>Spark</button>
    <button class="tt32b" data-t>Bloom</button>
    <button class="tt32b" data-t>Burst</button>
    <button class="tt32b" data-t>Flare</button>
  </nav>
</div>
.tt32 {
  background: #1c1424;
  padding: 36px 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  box-sizing: border-box;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.tt32n {
  position: relative;
  display: flex;
  gap: 0;
  min-width: 0;
  width: 100%;
  max-width: 420px;
}

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

.tt32b {
  flex: 1;
  min-width: 0;
  padding: 10px 12px;
  border: 0;
  background: transparent;
  font: 600 12px/1 ui-sans-serif, system-ui;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.55);
  cursor: pointer;
  position: relative;
  transition: color 0.25s;
  white-space: nowrap;
}

.tt32b:hover {
  color: rgba(255, 255, 255, 0.9);
}

.tt32b.active {
  color: #ff6b6b;
}

.tt32b:focus-visible {
  outline: 2px solid #ff6b6b;
  outline-offset: 2px;
  border-radius: 4px;
}

.tt32spark {
  position: absolute;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  pointer-events: none;
  animation: tt32-fly 0.6s cubic-bezier(0.2, 0.6, 0.4, 1) forwards;
}

@keyframes tt32-fly {
  from {
    transform: translate(0, 0) scale(1);
    opacity: 1;
  }

  to {
    transform: translate(var(--dx), var(--dy)) scale(0.3);
    opacity: 0;
  }
}

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

  .tt32spark {
    animation: none;
    opacity: 0;
  }
}
/* Particle Burst — toggle .active, slide underline, spawn 8 color sparks
   on click. Re-positions the underline on viewport resize. */
(function () {
  var nav = document.querySelector(".tt32n");
  if (!nav) return;
  var btns = nav.querySelectorAll("[data-t]");
  var line = nav.querySelector(".tt32line");
  var current = null;
  var COLORS = ["#ff6b6b", "#ffd166", "#06d6a0", "#118ab2", "#ef476f"];

  function reposition() {
    if (!current || !line) return;
    line.style.left = current.offsetLeft + "px";
    line.style.width = current.offsetWidth + "px";
  }
  function burst(btn) {
    var rect = btn.getBoundingClientRect();
    var navRect = nav.getBoundingClientRect();
    var cx = rect.left - navRect.left + rect.width / 2;
    var cy = rect.top - navRect.top + rect.height / 2;
    for (var i = 0; i < 8; i++) {
      var s = document.createElement("span");
      s.className = "tt32spark";
      s.style.left = cx + "px";
      s.style.top = cy + "px";
      s.style.background = COLORS[i % COLORS.length];
      var angle = (i / 8) * Math.PI * 2;
      var dist = 32 + Math.random() * 18;
      s.style.setProperty("--dx", Math.cos(angle) * dist + "px");
      s.style.setProperty("--dy", Math.sin(angle) * dist + "px");
      nav.appendChild(s);
      setTimeout(
        (function (n) {
          return function () {
            if (n.parentNode) n.parentNode.removeChild(n);
          };
        })(s),
        700,
      );
    }
  }
  function activate(btn, withBurst) {
    current = btn;
    btns.forEach(function (b) {
      b.classList.toggle("active", b === btn);
    });
    reposition();
    if (withBurst) burst(btn);
  }
  btns.forEach(function (b) {
    b.addEventListener("click", function () {
      activate(b, true);
    });
  });
  window.addEventListener("resize", reposition);
  var initial = nav.querySelector("[data-t].active") || btns[0];
  if (initial) activate(initial, false);
})();
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: Particle Burst
Source: https://codefronts.com/navigation/css-tabs/particle-burst/

Each tab click spawns 8 small color sparks that fly outward and fade. JS positions each particle at the click point; CSS animates trajectory + fade. The active tab is also highlighted with a coral underline.
## HTML
```html
<div class="tt32">
  <nav class="tt32n">
    <span class="tt32line" aria-hidden="true"></span>
    <button class="tt32b active" data-t>Spark</button>
    <button class="tt32b" data-t>Bloom</button>
    <button class="tt32b" data-t>Burst</button>
    <button class="tt32b" data-t>Flare</button>
  </nav>
</div>
```
## CSS
```css
.tt32 {
  background: #1c1424;
  padding: 36px 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  box-sizing: border-box;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.tt32n {
  position: relative;
  display: flex;
  gap: 0;
  min-width: 0;
  width: 100%;
  max-width: 420px;
}

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

.tt32b {
  flex: 1;
  min-width: 0;
  padding: 10px 12px;
  border: 0;
  background: transparent;
  font: 600 12px/1 ui-sans-serif, system-ui;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.55);
  cursor: pointer;
  position: relative;
  transition: color 0.25s;
  white-space: nowrap;
}

.tt32b:hover {
  color: rgba(255, 255, 255, 0.9);
}

.tt32b.active {
  color: #ff6b6b;
}

.tt32b:focus-visible {
  outline: 2px solid #ff6b6b;
  outline-offset: 2px;
  border-radius: 4px;
}

.tt32spark {
  position: absolute;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  pointer-events: none;
  animation: tt32-fly 0.6s cubic-bezier(0.2, 0.6, 0.4, 1) forwards;
}

@keyframes tt32-fly {
  from {
    transform: translate(0, 0) scale(1);
    opacity: 1;
  }

  to {
    transform: translate(var(--dx), var(--dy)) scale(0.3);
    opacity: 0;
  }
}

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

  .tt32spark {
    animation: none;
    opacity: 0;
  }
}
```

## JavaScript
```js
/* Particle Burst — toggle .active, slide underline, spawn 8 color sparks
   on click. Re-positions the underline on viewport resize. */
(function () {
  var nav = document.querySelector(".tt32n");
  if (!nav) return;
  var btns = nav.querySelectorAll("[data-t]");
  var line = nav.querySelector(".tt32line");
  var current = null;
  var COLORS = ["#ff6b6b", "#ffd166", "#06d6a0", "#118ab2", "#ef476f"];

  function reposition() {
    if (!current || !line) return;
    line.style.left = current.offsetLeft + "px";
    line.style.width = current.offsetWidth + "px";
  }
  function burst(btn) {
    var rect = btn.getBoundingClientRect();
    var navRect = nav.getBoundingClientRect();
    var cx = rect.left - navRect.left + rect.width / 2;
    var cy = rect.top - navRect.top + rect.height / 2;
    for (var i = 0; i < 8; i++) {
      var s = document.createElement("span");
      s.className = "tt32spark";
      s.style.left = cx + "px";
      s.style.top = cy + "px";
      s.style.background = COLORS[i % COLORS.length];
      var angle = (i / 8) * Math.PI * 2;
      var dist = 32 + Math.random() * 18;
      s.style.setProperty("--dx", Math.cos(angle) * dist + "px");
      s.style.setProperty("--dy", Math.sin(angle) * dist + "px");
      nav.appendChild(s);
      setTimeout(
        (function (n) {
          return function () {
            if (n.parentNode) n.parentNode.removeChild(n);
          };
        })(s),
        700,
      );
    }
  }
  function activate(btn, withBurst) {
    current = btn;
    btns.forEach(function (b) {
      b.classList.toggle("active", b === btn);
    });
    reposition();
    if (withBurst) burst(btn);
  }
  btns.forEach(function (b) {
    b.addEventListener("click", function () {
      activate(b, true);
    });
  });
  window.addEventListener("resize", reposition);
  var initial = nav.querySelector("[data-t].active") || btns[0];
  if (initial) activate(initial, false);
})();
```

How this works

The particle-burst tabs pair four button elements with a .tt32line span inside .tt32n. A small IIFE queries the nav, caches the buttons and the line, and defines three functions: reposition sets the line's left and width to match the current button's offsetLeft and offsetWidth; activate toggles the .active class on the clicked button and repositions the line; burst spawns the sparks. The initial button gets activated without a burst on load, so the first paint is quiet.

Every click calls burst(btn), which reads the button's getBoundingClientRect(), subtracts the nav's rect to get a nav-relative centre, then loops i=0 to 8. Each iteration creates a .tt32spark span, positions it at the centre, tints it from a five-colour rotation (#ff6b6b, #ffd166, #06d6a0, #118ab2, #ef476f), and sets --dx/--dy from Math.cos/sin(angle) * dist where dist is 32-50px. The CSS tt32-fly keyframe animates transform:translate(var(--dx),var(--dy)) scale(0.3) and fades opacity to 0 over 600ms with a decelerating cubic-bezier. A 700ms setTimeout removes each node from the DOM.

The underline uses a 400ms cubic-bezier(0.65,0,0.35,1) transition on left and width, which reads as a fluid slide rather than a snap. A resize listener re-runs reposition so container reflows do not orphan the underline. The prefers-reduced-motion:reduce block strips both the line transition and the spark animation, and sets sparks to opacity:0 so they are inert if any leak through. Colour system is coral #ff6b6b on plum #1c1424 at rest, five-hex confetti on click.

Make it yours

  • Retune the burst count by changing the i < 8 loop bound. Six particles feel cleaner for a docs tab; twelve reads as celebratory. Keep dist between 24 and 64px so sparks land near the tab label, not across the whole nav.
  • Swap the confetti palette by editing the COLORS array. Match your product accents but keep five distinct hues so consecutive clicks do not paint the same coloured spray. Monochrome white variants also read cleanly on dark plum.
  • Tighten the underline by changing height:2px on .tt32line and adjusting bottom:-2px in tandem. A 3px bar plus border-radius:2px feels bolder without breaking the transition math on left/width.
  • Extend the fly duration by editing 0.6s in tt32-fly and the matching 700 ms cleanup timeout. Keep cleanup at least 100ms longer than the animation, or DOM nodes get removed mid-transform and pop out of frame.
  • Slow the underline by editing the two 0.4s transition durations on .tt32line. A 250ms slide feels snappy for a settings panel; 600ms suits an editorial reader. Keep both durations identical or the line skews mid-glide.

Gotchas — read before shipping

  • The buttons are bare button elements with no ARIA tab semantics. Screen readers hear four buttons, not a tablist. Add role="tablist" on .tt32n, role="tab" plus aria-selected on each button, and a linked role="tabpanel" if you attach panels.
  • The nav has no keyboard arrow-key handling. Users tab through each button, which contradicts WAI-ARIA tab pattern expectations. Add keydown for ArrowLeft/ArrowRight to move focus and set tabindex="-1" on inactive tabs, or leave it as a button group and rebrand.
  • The burst function reads getBoundingClientRect() on every click, which forces layout. On a page with heavy DOM, rapid clicks can spike INP past 200ms. Cache the nav rect and invalidate on resize instead of measuring per-click.
  • The resize listener is added but never removed. In a React or Vue tree, unmounting the nav leaves the closure alive and the underline calculations fire against a detached node. Wrap the IIFE in a mount lifecycle and remove the listener on cleanup.
  • The setTimeout cleanup at 700ms assumes the animation completes. If the tab is on a hidden panel when animation would run (visibility:hidden or offscreen), the timeout still fires and orphan sparks accumulate. Use animationend events for SSR-safe cleanup instead.
  • The five-colour palette hardcodes coral and pink hues that clash with a red-primary product. Pull the array from a CSS custom property array or a config export so brand teams can swap palettes without editing the script. The active-tab colour #ff6b6b still needs manual sync.

Browser support

ChromeSafariFirefoxEdge
88+14+78+88+

CSS custom properties in keyframes (--dx/--dy) need Chrome 88, Safari 14, Firefox 78. Everything else is baseline. IE and legacy Edge are out of scope — treat the underline transition as the graceful fallback.

Techniques used in this demo

Search CodeFronts

Loading…