32 CSS Tab Designs31 / 32

CSS + JSMIT licensed

Velvet Ribbon

Icon nav on warm-linen stock with a velvet aubergine ribbon trimmed in gilt. The ribbon hangs down from the top rail directly above the active icon — measured live so it stays perfectly aligned at any stage width. Jewel-toned, club-bookplate atmosphere.

Published Updated

Live Demo
Try it

The code

<div class="tt19">
  <nav class="tt19n">
    <span class="tt19rail" aria-hidden="true"> </span>
    <span class="tt19ribbon" aria-hidden="true"> </span>
    <button class="tt19b active" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M3 11l9-8 9 8v10a2 2 0 0 1-2 2h-4v-7H9v7H5a2 2 0 0 1-2-2z" />
      </svg>
      <span class="tt19l">Home</span>
    </button>
    <button class="tt19b" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="11" cy="11" r="7" />
        <line x1="21" y1="21" x2="16" y2="16" />
      </svg>
      <span class="tt19l">Find</span>
    </button>
    <button class="tt19b" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <path
          d="M9 11h.01M15 11h.01M9.5 15a3.5 3.5 0 0 0 5 0M3 12a9 9 0 1 1 18 0 9 9 0 0 1-18 0z"
        />
      </svg>
      <span class="tt19l">Help</span>
    </button>
    <button class="tt19b" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="12" cy="8" r="4" />
        <path d="M3 21v-2a7 7 0 0 1 7-7h4a7 7 0 0 1 7 7v2" />
      </svg>
      <span class="tt19l">Me</span>
    </button>
  </nav>
</div>
.tt19 {
  background: #f5efe0;
  padding: 0;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  display: flex;
  align-items: center;
  width: 100%;
  box-sizing: border-box;
}

.tt19n {
  position: relative;
  flex: 1;
  display: flex;
  padding: 28px 12px 24px;
  box-sizing: border-box;
  min-width: 0;
}
/* Top rail running edge-to-edge — the "shelf" the ribbon hangs from */

.tt19rail {
  position: absolute;
  top: 28px;
  left: 0;
  right: 0;
  height: 3px;
  background: #3d1e4a;
  pointer-events: none;
}
/* The ribbon — JS positions it horizontally to track the active button.
   It hangs from the top rail and overlaps the icon column behind it.
   Height is sized so icon + label both sit comfortably inside it with
   breathing room above the triangular tail. */

.tt19ribbon {
  position: absolute;
  top: 28px;
  width: 56px;
  height: 84px;
  background: #3d1e4a;
  clip-path: polygon(0 0, 100% 0, 100% calc(100% - 10px), 50% 100%, 0 calc(100% - 10px));
  filter: drop-shadow(0 4px 8px rgba(61, 30, 74, 0.4));
  pointer-events: none;
  transition: left 0.5s cubic-bezier(0.65, 0, 0.35, 1);
}
/* Single gilt hem stripe across the ribbon, just below the label.
   Echoes the aubergine rail above and reads as a bookbinder's gilt edge. */

.tt19ribbon::before {
  content: "";
  position: absolute;
  left: 8px;
  right: 8px;
  bottom: 14px;
  height: 1.5px;
  background: #e6c149;
  z-index: 2;
}

.tt19b {
  position: relative;
  z-index: 1;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 14px 4px 12px;
  min-width: 0;
  border: 0;
  background: transparent;
  font: 700 10px/1 ui-sans-serif, system-ui;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: rgba(61, 30, 74, 0.5);
  cursor: pointer;
  transition: color 0.3s;
}

.tt19b:hover {
  color: rgba(61, 30, 74, 0.85);
}

.tt19i {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), stroke 0.3s;
}

.tt19l {
  font-weight: 700;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
/* Active state: icon + label go cream on aubergine (both sit inside ribbon
   body, with the gilt hem stripe between the label and the triangular tail). */

.tt19b.active {
  color: #f5efe0;
}

.tt19b.active .tt19i {
  stroke: #f5efe0;
}

@media (prefers-reduced-motion: reduce) {
  .tt19ribbon {
    transition: none;
  }
}
/* Velvet Ribbon — toggle .active and slide ribbon centered above active icon.
   Re-measures on viewport resize so the ribbon stays aligned at any width. */
(function () {
  var nav = document.querySelector(".tt19n");
  if (!nav) return;
  var btns = nav.querySelectorAll("[data-t]");
  var ribbon = nav.querySelector(".tt19ribbon");
  var current = null;

  function position(btn) {
    if (!ribbon || !btn) return;
    var w = ribbon.offsetWidth || 56;
    ribbon.style.left = btn.offsetLeft + btn.offsetWidth / 2 - w / 2 + "px";
  }
  function activate(btn) {
    current = btn;
    btns.forEach(function (b) {
      b.classList.toggle("active", b === btn);
    });
    position(btn);
  }
  btns.forEach(function (b) {
    b.addEventListener("click", function () {
      activate(b);
    });
  });
  window.addEventListener("resize", function () {
    position(current);
  });
  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: Velvet Ribbon
Source: https://codefronts.com/navigation/css-tabs/velvet-ribbon/

Icon nav on warm-linen stock with a velvet aubergine ribbon trimmed in gilt. The ribbon hangs down from the top rail directly above the active icon — measured live so it stays perfectly aligned at any stage width. Jewel-toned, club-bookplate atmosphere.
## HTML
```html
<div class="tt19">
  <nav class="tt19n">
    <span class="tt19rail" aria-hidden="true"> </span>
    <span class="tt19ribbon" aria-hidden="true"> </span>
    <button class="tt19b active" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <path d="M3 11l9-8 9 8v10a2 2 0 0 1-2 2h-4v-7H9v7H5a2 2 0 0 1-2-2z" />
      </svg>
      <span class="tt19l">Home</span>
    </button>
    <button class="tt19b" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="11" cy="11" r="7" />
        <line x1="21" y1="21" x2="16" y2="16" />
      </svg>
      <span class="tt19l">Find</span>
    </button>
    <button class="tt19b" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <path
          d="M9 11h.01M15 11h.01M9.5 15a3.5 3.5 0 0 0 5 0M3 12a9 9 0 1 1 18 0 9 9 0 0 1-18 0z"
        />
      </svg>
      <span class="tt19l">Help</span>
    </button>
    <button class="tt19b" data-t>
      <svg class="tt19i" viewBox="0 0 24 24" aria-hidden="true">
        <circle cx="12" cy="8" r="4" />
        <path d="M3 21v-2a7 7 0 0 1 7-7h4a7 7 0 0 1 7 7v2" />
      </svg>
      <span class="tt19l">Me</span>
    </button>
  </nav>
</div>
```
## CSS
```css
.tt19 {
  background: #f5efe0;
  padding: 0;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  display: flex;
  align-items: center;
  width: 100%;
  box-sizing: border-box;
}

.tt19n {
  position: relative;
  flex: 1;
  display: flex;
  padding: 28px 12px 24px;
  box-sizing: border-box;
  min-width: 0;
}
/* Top rail running edge-to-edge — the "shelf" the ribbon hangs from */

.tt19rail {
  position: absolute;
  top: 28px;
  left: 0;
  right: 0;
  height: 3px;
  background: #3d1e4a;
  pointer-events: none;
}
/* The ribbon — JS positions it horizontally to track the active button.
   It hangs from the top rail and overlaps the icon column behind it.
   Height is sized so icon + label both sit comfortably inside it with
   breathing room above the triangular tail. */

.tt19ribbon {
  position: absolute;
  top: 28px;
  width: 56px;
  height: 84px;
  background: #3d1e4a;
  clip-path: polygon(0 0, 100% 0, 100% calc(100% - 10px), 50% 100%, 0 calc(100% - 10px));
  filter: drop-shadow(0 4px 8px rgba(61, 30, 74, 0.4));
  pointer-events: none;
  transition: left 0.5s cubic-bezier(0.65, 0, 0.35, 1);
}
/* Single gilt hem stripe across the ribbon, just below the label.
   Echoes the aubergine rail above and reads as a bookbinder's gilt edge. */

.tt19ribbon::before {
  content: "";
  position: absolute;
  left: 8px;
  right: 8px;
  bottom: 14px;
  height: 1.5px;
  background: #e6c149;
  z-index: 2;
}

.tt19b {
  position: relative;
  z-index: 1;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 14px 4px 12px;
  min-width: 0;
  border: 0;
  background: transparent;
  font: 700 10px/1 ui-sans-serif, system-ui;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: rgba(61, 30, 74, 0.5);
  cursor: pointer;
  transition: color 0.3s;
}

.tt19b:hover {
  color: rgba(61, 30, 74, 0.85);
}

.tt19i {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), stroke 0.3s;
}

.tt19l {
  font-weight: 700;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
/* Active state: icon + label go cream on aubergine (both sit inside ribbon
   body, with the gilt hem stripe between the label and the triangular tail). */

.tt19b.active {
  color: #f5efe0;
}

.tt19b.active .tt19i {
  stroke: #f5efe0;
}

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

## JavaScript
```js
/* Velvet Ribbon — toggle .active and slide ribbon centered above active icon.
   Re-measures on viewport resize so the ribbon stays aligned at any width. */
(function () {
  var nav = document.querySelector(".tt19n");
  if (!nav) return;
  var btns = nav.querySelectorAll("[data-t]");
  var ribbon = nav.querySelector(".tt19ribbon");
  var current = null;

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

How this works

The .tt19 bar reads as a boutique-heritage bookplate — icon nav on warm linen #f5efe0 with an aubergine velvet ribbon at #3d1e4a that hangs from a top rail. The rail itself is a 3px absolutely-positioned span running edge-to-edge at top:28px, painted the same aubergine, so the ribbon appears to physically hang from a horizontal shelf rather than float in space. The ribbon is a 56px-wide, 84px-tall span clipped via polygon(0 0, 100% 0, 100% calc(100% - 10px), 50% 100%, 0 calc(100% - 10px)) — flat top, flat sides, triangular swallow-tail bottom, exactly how a real bookplate ribbon is cut.

A single gilt hem stripe at #e6c149 sits 14px above the tail via ::before, echoing a bookbinder's gilt edge below the label. A drop shadow at rgba(61, 30, 74, 0.4) gives the ribbon 4px of physical lift off the linen. The mechanic is measured live in JS: on click, the handler reads the active button's offsetLeft + offsetWidth/2 - ribbonWidth/2 and writes it to the ribbon's inline left. The 0.5s cubic-bezier(0.65, 0, 0.35, 1) transition on left makes the ribbon glide horizontally between icon positions, staying centered at any stage width.

The active button flips its icon stroke and label color from rgba(61, 30, 74, 0.5) to cream #f5efe0, so the icon-plus-label group reads as embroidered inside the velvet ribbon body. Semantic button elements with data-t hooks keep the markup clean and framework-agnostic. A @media (prefers-reduced-motion:reduce) guard is baked in — the ribbon transition drops to none for users on that setting, satisfying WCAG 2.1 SC 2.3.3 out of the box. SSR-safe because the initial .active class is authored in the markup; INP-safe because each click writes one inline style and toggles two classes.

Make it yours

  • Swap the aubergine #3d1e4a for oxblood #5c1a1a, forest #1e3a2e, or midnight #1e293b. Match the rail color exactly to the ribbon so they read as one continuous velvet run. Keep the gilt hem at #e6c149 or shift to rose gold #c99a7a for a warmer boutique tone.
  • The linen stock #f5efe0 is what carries the heritage register. Swap to bone #f0ebe0, ivory #fdf6e3, or cream #f4f3ee — avoid pure white, which flattens the ribbon shadow and kills the tactile lift the whole component depends on.
  • Retune the ribbon dimensions: 56px wide is calibrated for 4 tabs across a ~600px stage. For 3 tabs, widen to 72px; for 5-6, narrow to 44px. Update the height proportionally so the icon-plus-label pair keeps 24px of breathing room above the swallow-tail.
  • Replace the outline icons with your icon library — the SVGs use stroke:currentColor, so they inherit the active color transition automatically. Keep stroke-width:1.8 and the 24-unit viewBox for optical parity with the Home/Find/Help/Me set.
  • For a wider institutional register, double the gilt to two hem stripes — clone the ::before to ::after, position the second stripe 4px below the first. Reads as a club-crest rosette rather than a single bookplate ribbon.

Gotchas — read before shipping

  • The ribbon reads offsetLeft and offsetWidth from the active button. If any ancestor between .tt19n and the button uses transform, it becomes the offset parent and the math breaks. Keep .tt19n as the only position:relative ancestor in the chain.
  • The rail sits at top:28px, matching .tt19n's padding-top:28px. If you change the nav padding without updating the rail top-value in lockstep, the ribbon will hang from empty air rather than from a visible shelf — the shelf illusion depends on pixel-precise alignment between the two.
  • The ribbon's filter: drop-shadow() respects the clip-path outline, so the shadow follows the swallow-tail — a box-shadow would draw a rectangle behind the ribbon and leak past the tail. Do not swap drop-shadow for box-shadow without also flattening the clip-path.
  • The resize handler re-runs synchronously on every viewport tick. On mobile Safari's rubber-band scroll this fires dozens of times per second. Wrap the position call in requestAnimationFrame or a 100ms debounce to keep INP below the 200ms tier-1 threshold.
  • The 0.12em letter-spacing on .tt19l plus text-transform:uppercase is what carries the club-bookplate voice. Dropping either turns the label into standard app-nav copy and loses the heritage register — the typographic treatment is not decorative, it is the identity.
  • The active label uses cream on aubergine at roughly 12.4:1 contrast — passing WCAG 2.1 AAA for text over 18.66px, but the label at 10px sits below the large-text threshold. If your audit is strict, bump the label to 11px or lift the cream to full white #ffffff to buy another 0.5 ratio points.

Browser support

ChromeSafariFirefoxEdge
76+13.1+72+76+

filter: drop-shadow() on a clip-path element is stable in every evergreen browser since 2020. The measured-JS positioning works everywhere offsetLeft reports correctly — effectively all tier-1 traffic.

Techniques used in this demo

Search CodeFronts

Loading…