Pure TailwindMIT licensed

Pure Tailwind Icon Rail Sidebar with Accessible Labels

An icon-only rail has one hard requirement and one nice-to-have, and most implementations get them backwards. The requirement is an accessible name on every link — here a visually hidden <span>, never title. The nice-to-have is the sighted user's tooltip, and in utilities it is group-hover: and group-focus-visible: on a second span. Zero script.

Live Demo
Plain CSSTailwind v4
Try it

Mosaic by Cruip: a complete Tailwind admin dashboard template in HTML, React, Next.js and Vue · $69

Cruip · Mosaic by Cruip · Affiliate link

View Mosaic dashboard

The code

<section class="tsb-13 block relative w-full min-h-svh bg-tsb13-bg text-tsb13-ink font-tsb13-sans leading-[1.55]" aria-label="Aperture media library">
  <div class="absolute top-3.5 right-3.5 z-60">
    <input class="tsb-13-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-13-theme">
    <label class="grid place-items-center w-11 h-11 border border-tsb13-line bg-tsb13-surf text-tsb13-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb13-acc peer-focus-visible:outline-offset-2" for="tsb-13-theme">
      <svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
      <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
    </label>
  </div>

  <div class="flex min-w-0 min-h-svh bg-tsb13-surf">
    <nav class="flex-none min-w-0 w-[var(--tsb13-w)] bg-tsb13-surf2 border-r border-tsb13-line" aria-label="Aperture library">
      <ul class="list-none m-0 p-1 grid gap-1">
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb13-accsoft aria-[current=page]:text-tsb13-acc aria-[current=page]:shadow-[inset_3px_0_0_var(--color-tsb13-acc)]" href="#tsb-13-main" aria-current="page">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M4 15l4-4 4 4 3-3 5 5" fill="none" stroke="currentColor" stroke-width="1.7"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">All media</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">All media</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h6l2 2h8v9H4Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Collections</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Collections</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M15.5 15.5L20 20" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Saved searches</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Saved searches</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 4l7 4v8l-7 4-7-4V8Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Renditions</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Renditions</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 19V5h9l5 5v9Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M9 13h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Licences</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Licences</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 4h12v16l-6-4-6 4Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Archive</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Archive</span></a></li>
      </ul>
    </nav>

    <main class="flex-1 min-w-0 grid gap-4.5 content-start px-5 pt-5.5 pb-10 r46:px-8 r46:pt-9 r46:pb-12" id="tsb-13-main">
      <div class="min-w-0">
        <h2 class="m-0 mb-1 font-tsb13-display text-[clamp(1.5rem,3.6vw,2rem)] tracking-[0.02em] uppercase">All media</h2>
        <p class="m-0 text-[0.84rem] tabular-nums text-tsb13-mut">1,842 assets &middot; 41 GB &middot; last ingest 14:02</p>
      </div>
      <div class="grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2 [&>span]:aspect-[4/3] [&>span]:bg-tsb13-surf2 [&>span]:border [&>span]:border-tsb13-line [&>span:nth-child(3n)]:bg-tsb13-accsoft" aria-hidden="true">
        <span></span><span></span><span></span><span></span><span></span><span></span>
        <span></span><span></span><span></span><span></span><span></span><span></span>
        <span></span><span></span><span></span><span></span><span></span><span></span>
      </div>
    </main>
  </div>
</section>
@import "tailwindcss";

@theme {
  --color-tsb13-bg: #f4f4f5;
  --color-tsb13-bg: oklch(96% 0.002 285);
  --color-tsb13-surf: #ffffff;
  --color-tsb13-surf: oklch(100% 0 0);
  --color-tsb13-surf2: #e9e9ec;
  --color-tsb13-surf2: oklch(92.5% 0.004 285);
  --color-tsb13-line: #d3d3d8;
  --color-tsb13-line: oklch(86.5% 0.006 285);
  --color-tsb13-ink: #131316;
  --color-tsb13-ink: oklch(19% 0.004 285);
  --color-tsb13-mut: #5c5c66;
  --color-tsb13-mut: oklch(46% 0.012 285);
  --color-tsb13-acc: #0f5c8c;
  --color-tsb13-acc: oklch(45% 0.1 232);
  --color-tsb13-accsoft: #dceaf4;
  --color-tsb13-accsoft: oklch(92% 0.03 232);
  --font-tsb13-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-tsb13-display: 'Roboto Condensed', 'Archivo Narrow', ui-sans-serif, system-ui, sans-serif;
  --breakpoint-r46: 46rem;
}
/* Root rule: the full-bleed contract plus the rail width, which is geometry read by
   w-[var(--tsb13-w)] rather than a colour scale. */

.tsb-13 {
  width: 100%;
  min-height: 100svh;
  --tsb13-w: 4rem;
}

.tsb-13 *,
.tsb-13 *::before,
.tsb-13 *::after {
  box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */

.tsb-13:has(.tsb-13-theme-input:checked) {
  --color-tsb13-bg: #0d0d0f;
  --color-tsb13-surf: #15151a;
  --color-tsb13-surf2: #1d1d23;
  --color-tsb13-line: #2a2a31;
  --color-tsb13-ink: #eeeef1;
  --color-tsb13-mut: #9a9aa4;
  --color-tsb13-acc: #6fbde8;
  --color-tsb13-acc: oklch(78% 0.1 232);
  --color-tsb13-accsoft: #16283a;
}

@media (prefers-color-scheme: dark) {
  .tsb-13 {
    --color-tsb13-bg: #0d0d0f;
    --color-tsb13-surf: #15151a;
    --color-tsb13-surf2: #1d1d23;
    --color-tsb13-line: #2a2a31;
    --color-tsb13-ink: #eeeef1;
    --color-tsb13-mut: #9a9aa4;
    --color-tsb13-acc: #6fbde8;
    --color-tsb13-acc: oklch(78% 0.1 232);
    --color-tsb13-accsoft: #16283a;
  }

  .tsb-13:has(.tsb-13-theme-input:checked) {
    --color-tsb13-bg: #f4f4f5;
    --color-tsb13-surf: #ffffff;
    --color-tsb13-surf2: #e9e9ec;
    --color-tsb13-line: #d3d3d8;
    --color-tsb13-ink: #131316;
    --color-tsb13-mut: #5c5c66;
    --color-tsb13-acc: #0f5c8c;
    --color-tsb13-acc: oklch(45% 0.1 232);
    --color-tsb13-accsoft: #dceaf4;
  }
}

@media (prefers-color-scheme: light) {
  .tsb-13:has(.tsb-13-theme-input:checked) {
    --color-tsb13-bg: #0d0d0f;
    --color-tsb13-surf: #15151a;
    --color-tsb13-surf2: #1d1d23;
    --color-tsb13-line: #2a2a31;
    --color-tsb13-ink: #eeeef1;
    --color-tsb13-mut: #9a9aa4;
    --color-tsb13-acc: #6fbde8;
    --color-tsb13-acc: oklch(78% 0.1 232);
    --color-tsb13-accsoft: #16283a;
  }
}

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";

@theme {
  --color-tsb13-bg: #f4f4f5;
  --color-tsb13-bg: oklch(96% 0.002 285);
  --color-tsb13-surf: #ffffff;
  --color-tsb13-surf: oklch(100% 0 0);
  --color-tsb13-surf2: #e9e9ec;
  --color-tsb13-surf2: oklch(92.5% 0.004 285);
  --color-tsb13-line: #d3d3d8;
  --color-tsb13-line: oklch(86.5% 0.006 285);
  --color-tsb13-ink: #131316;
  --color-tsb13-ink: oklch(19% 0.004 285);
  --color-tsb13-mut: #5c5c66;
  --color-tsb13-mut: oklch(46% 0.012 285);
  --color-tsb13-acc: #0f5c8c;
  --color-tsb13-acc: oklch(45% 0.1 232);
  --color-tsb13-accsoft: #dceaf4;
  --color-tsb13-accsoft: oklch(92% 0.03 232);
  --font-tsb13-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-tsb13-display: 'Roboto Condensed', 'Archivo Narrow', ui-sans-serif, system-ui, sans-serif;
  --breakpoint-r46: 46rem;
}
/* Root rule: the full-bleed contract plus the rail width, which is geometry read by
   w-[var(--tsb13-w)] rather than a colour scale. */

.tsb-13 {
  width: 100%;
  min-height: 100svh;
  --tsb13-w: 4rem;
}

.tsb-13 *,
.tsb-13 *::before,
.tsb-13 *::after {
  box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */

.tsb-13:has(.tsb-13-theme-input:checked) {
  --color-tsb13-bg: #0d0d0f;
  --color-tsb13-surf: #15151a;
  --color-tsb13-surf2: #1d1d23;
  --color-tsb13-line: #2a2a31;
  --color-tsb13-ink: #eeeef1;
  --color-tsb13-mut: #9a9aa4;
  --color-tsb13-acc: #6fbde8;
  --color-tsb13-acc: oklch(78% 0.1 232);
  --color-tsb13-accsoft: #16283a;
}

@media (prefers-color-scheme: dark) {
  .tsb-13 {
    --color-tsb13-bg: #0d0d0f;
    --color-tsb13-surf: #15151a;
    --color-tsb13-surf2: #1d1d23;
    --color-tsb13-line: #2a2a31;
    --color-tsb13-ink: #eeeef1;
    --color-tsb13-mut: #9a9aa4;
    --color-tsb13-acc: #6fbde8;
    --color-tsb13-acc: oklch(78% 0.1 232);
    --color-tsb13-accsoft: #16283a;
  }

  .tsb-13:has(.tsb-13-theme-input:checked) {
    --color-tsb13-bg: #f4f4f5;
    --color-tsb13-surf: #ffffff;
    --color-tsb13-surf2: #e9e9ec;
    --color-tsb13-line: #d3d3d8;
    --color-tsb13-ink: #131316;
    --color-tsb13-mut: #5c5c66;
    --color-tsb13-acc: #0f5c8c;
    --color-tsb13-acc: oklch(45% 0.1 232);
    --color-tsb13-accsoft: #dceaf4;
  }
}

@media (prefers-color-scheme: light) {
  .tsb-13:has(.tsb-13-theme-input:checked) {
    --color-tsb13-bg: #0d0d0f;
    --color-tsb13-surf: #15151a;
    --color-tsb13-surf2: #1d1d23;
    --color-tsb13-line: #2a2a31;
    --color-tsb13-ink: #eeeef1;
    --color-tsb13-mut: #9a9aa4;
    --color-tsb13-acc: #6fbde8;
    --color-tsb13-acc: oklch(78% 0.1 232);
    --color-tsb13-accsoft: #16283a;
  }
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@400;600;700&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The tooltip is the textbook group case: group on the link, group-hover: and group-focus-visible: on the span. The CSS version needs two selectors written elsewhere; here the trigger and the response are one element apart.
  • invisible opacity-0 resting, both flipped in the variants — opacity-0 alone would leave a hit-testable, sometimes-announced element floating beside the rail.
  • transition-[opacity,visibility] is a composed transition with no utility form on the scale, so it is written as an arbitrary property list — the same technique as demo 02's delayed visibility.
  • [&>svg]:w-5.5 keeps icon sizing on the link, which matters here because the link has two span children that must NOT be sized like icons.
  • [&>span:nth-child(3n)]:bg-tsb13-accsoft on the asset grid is a genuine nth-child port: Tailwind's nth-* variants need static values, and as a child selector on the parent it stays one utility for eighteen tiles.
  • Zero JS, and the rail is 4rem — the whole demo is the argument that icon-only rails are an accessibility problem, not a layout problem.
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 demo 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: Pure Tailwind Icon Rail Sidebar with Accessible Labels
Source: https://codefronts.com/navigation/tailwind-css-sidebars/pure-tailwind-icon-rail-sidebar-with-accessible-labels/

An icon-only rail has one hard requirement and one nice-to-have, and most implementations get them backwards. The requirement is an accessible name on every link &mdash; here a visually hidden <span>, never title. The nice-to-have is the sighted user's tooltip, and in utilities it is group-hover: and group-focus-visible: on a second span. Zero script.
## HTML
```html
<section class="tsb-13 block relative w-full min-h-svh bg-tsb13-bg text-tsb13-ink font-tsb13-sans leading-[1.55]" aria-label="Aperture media library">
  <div class="absolute top-3.5 right-3.5 z-60">
    <input class="tsb-13-theme-input peer absolute inset-0 w-full h-full m-0 opacity-0 cursor-pointer" type="checkbox" id="tsb-13-theme">
    <label class="grid place-items-center w-11 h-11 border border-tsb13-line bg-tsb13-surf text-tsb13-ink cursor-pointer peer-focus-visible:outline-2 peer-focus-visible:outline-tsb13-acc peer-focus-visible:outline-offset-2" for="tsb-13-theme">
      <svg class="w-4.5 h-4.5" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17Z" fill="currentColor"/></svg>
      <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Toggle dark theme</span>
    </label>
  </div>

  <div class="flex min-w-0 min-h-svh bg-tsb13-surf">
    <nav class="flex-none min-w-0 w-[var(--tsb13-w)] bg-tsb13-surf2 border-r border-tsb13-line" aria-label="Aperture library">
      <ul class="list-none m-0 p-1 grid gap-1">
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2 aria-[current=page]:bg-tsb13-accsoft aria-[current=page]:text-tsb13-acc aria-[current=page]:shadow-[inset_3px_0_0_var(--color-tsb13-acc)]" href="#tsb-13-main" aria-current="page">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M4 15l4-4 4 4 3-3 5 5" fill="none" stroke="currentColor" stroke-width="1.7"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">All media</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">All media</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h6l2 2h8v9H4Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Collections</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Collections</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M15.5 15.5L20 20" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Saved searches</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Saved searches</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 4l7 4v8l-7 4-7-4V8Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Renditions</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Renditions</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 19V5h9l5 5v9Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M9 13h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Licences</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Licences</span></a></li>
        <li><a class="group relative grid place-items-center h-11 min-h-11 text-tsb13-mut no-underline [&>svg]:w-5.5 [&>svg]:h-5.5 hover:bg-tsb13-surf hover:text-tsb13-ink focus-visible:outline-2 focus-visible:outline-tsb13-acc focus-visible:-outline-offset-2" href="#tsb-13-main">
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 4h12v16l-6-4-6 4Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
          <span class="absolute w-px h-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]">Archive</span><span class="absolute left-[calc(100%+0.55rem)] top-1/2 -translate-y-1/2 z-8 px-2 py-1 whitespace-nowrap pointer-events-none invisible opacity-0 bg-tsb13-ink text-tsb13-bg font-tsb13-display text-[0.8rem] tracking-[0.04em] uppercase transition-[opacity,visibility] duration-[120ms] group-hover:visible group-hover:opacity-100 group-focus-visible:visible group-focus-visible:opacity-100 motion-reduce:transition-none" aria-hidden="true">Archive</span></a></li>
      </ul>
    </nav>

    <main class="flex-1 min-w-0 grid gap-4.5 content-start px-5 pt-5.5 pb-10 r46:px-8 r46:pt-9 r46:pb-12" id="tsb-13-main">
      <div class="min-w-0">
        <h2 class="m-0 mb-1 font-tsb13-display text-[clamp(1.5rem,3.6vw,2rem)] tracking-[0.02em] uppercase">All media</h2>
        <p class="m-0 text-[0.84rem] tabular-nums text-tsb13-mut">1,842 assets &middot; 41 GB &middot; last ingest 14:02</p>
      </div>
      <div class="grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2 [&>span]:aspect-[4/3] [&>span]:bg-tsb13-surf2 [&>span]:border [&>span]:border-tsb13-line [&>span:nth-child(3n)]:bg-tsb13-accsoft" aria-hidden="true">
        <span></span><span></span><span></span><span></span><span></span><span></span>
        <span></span><span></span><span></span><span></span><span></span><span></span>
        <span></span><span></span><span></span><span></span><span></span><span></span>
      </div>
    </main>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";

@theme {
  --color-tsb13-bg: #f4f4f5;
  --color-tsb13-bg: oklch(96% 0.002 285);
  --color-tsb13-surf: #ffffff;
  --color-tsb13-surf: oklch(100% 0 0);
  --color-tsb13-surf2: #e9e9ec;
  --color-tsb13-surf2: oklch(92.5% 0.004 285);
  --color-tsb13-line: #d3d3d8;
  --color-tsb13-line: oklch(86.5% 0.006 285);
  --color-tsb13-ink: #131316;
  --color-tsb13-ink: oklch(19% 0.004 285);
  --color-tsb13-mut: #5c5c66;
  --color-tsb13-mut: oklch(46% 0.012 285);
  --color-tsb13-acc: #0f5c8c;
  --color-tsb13-acc: oklch(45% 0.1 232);
  --color-tsb13-accsoft: #dceaf4;
  --color-tsb13-accsoft: oklch(92% 0.03 232);
  --font-tsb13-sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --font-tsb13-display: 'Roboto Condensed', 'Archivo Narrow', ui-sans-serif, system-ui, sans-serif;
  --breakpoint-r46: 46rem;
}
/* Root rule: the full-bleed contract plus the rail width, which is geometry read by
   w-[var(--tsb13-w)] rather than a colour scale. */

.tsb-13 {
  width: 100%;
  min-height: 100svh;
  --tsb13-w: 4rem;
}

.tsb-13 *,
.tsb-13 *::before,
.tsb-13 *::after {
  box-sizing: border-box;
}
/* Token flip for the pure-CSS theme toggle, plus the two mandatory mirror blocks. */

.tsb-13:has(.tsb-13-theme-input:checked) {
  --color-tsb13-bg: #0d0d0f;
  --color-tsb13-surf: #15151a;
  --color-tsb13-surf2: #1d1d23;
  --color-tsb13-line: #2a2a31;
  --color-tsb13-ink: #eeeef1;
  --color-tsb13-mut: #9a9aa4;
  --color-tsb13-acc: #6fbde8;
  --color-tsb13-acc: oklch(78% 0.1 232);
  --color-tsb13-accsoft: #16283a;
}

@media (prefers-color-scheme: dark) {
  .tsb-13 {
    --color-tsb13-bg: #0d0d0f;
    --color-tsb13-surf: #15151a;
    --color-tsb13-surf2: #1d1d23;
    --color-tsb13-line: #2a2a31;
    --color-tsb13-ink: #eeeef1;
    --color-tsb13-mut: #9a9aa4;
    --color-tsb13-acc: #6fbde8;
    --color-tsb13-acc: oklch(78% 0.1 232);
    --color-tsb13-accsoft: #16283a;
  }

  .tsb-13:has(.tsb-13-theme-input:checked) {
    --color-tsb13-bg: #f4f4f5;
    --color-tsb13-surf: #ffffff;
    --color-tsb13-surf2: #e9e9ec;
    --color-tsb13-line: #d3d3d8;
    --color-tsb13-ink: #131316;
    --color-tsb13-mut: #5c5c66;
    --color-tsb13-acc: #0f5c8c;
    --color-tsb13-acc: oklch(45% 0.1 232);
    --color-tsb13-accsoft: #dceaf4;
  }
}

@media (prefers-color-scheme: light) {
  .tsb-13:has(.tsb-13-theme-input:checked) {
    --color-tsb13-bg: #0d0d0f;
    --color-tsb13-surf: #15151a;
    --color-tsb13-surf2: #1d1d23;
    --color-tsb13-line: #2a2a31;
    --color-tsb13-ink: #eeeef1;
    --color-tsb13-mut: #9a9aa4;
    --color-tsb13-acc: #6fbde8;
    --color-tsb13-acc: oklch(78% 0.1 232);
    --color-tsb13-accsoft: #16283a;
  }
}
```

How this works

Two labels, two audiences, one link. The visually hidden span is the accessible name: it is in the accessibility tree, it is what a screen reader announces, and it is not affected by hover at all. The tooltip is decoration for sighted users and is aria-hidden, so the name is never announced twice.

<a class="group relative grid place-items-center h-11">
  <svg …>
  <span class="absolute w-px h-px … [clip-path:inset(50%)]">Collections</span>
  <span aria-hidden="true" class="invisible opacity-0
      group-hover:visible group-hover:opacity-100
      group-focus-visible:visible group-focus-visible:opacity-100">Collections</span>

Why not title. It never appears on touch, it is announced inconsistently, it cannot be styled, and its delay is not configurable. It is the single most common shortcut in icon rails and it fails the users it is supposed to help.

The keyboard twin is not optional. A tooltip that only responds to hover: leaves a keyboard user tabbing through six unlabelled squares. group-focus-visible: costs two utilities.

invisible rather than opacity-0. A zero-opacity tooltip still occupies space for hit-testing and can be picked up by some assistive tooling. invisible plus the opacity transition gives the fade AND the correct resting state.

Make it yours

  • Rail width is --tsb13-w in the root rule, read by w-[var(--tsb13-w)]; do not go below 44px of target.
  • Flip the tooltip to the other side by swapping left-[calc(100%+0.55rem)] for right-[calc(100%+0.55rem)].
  • Slow the fade with the duration-[120ms] on the tooltip, or delete the transition for an instant label.
  • Retheme through --color-tsb13-acc and --color-tsb13-accsoft.
  • Add a text label under each icon instead of a flyout if the rail can afford 5rem — then delete the tooltip span entirely and keep the visually hidden one.
  • Need the rail to expand rather than tooltip? That is demo 05 (scripted) or demo 11 (checkbox).

Gotchas — read before shipping

  • title is not an accessible name substitute. It is the shortcut this demo exists to replace.
  • A hover-only tooltip excludes keyboard users; the group-focus-visible: twin is required, not a nicety.
  • The tooltip must be aria-hidden AND the real label visually hidden — without the first, the name is announced twice.
  • pointer-events-none on the tooltip prevents it from stealing the hover and flickering on the boundary.
  • An icon target under 44px fails SC 2.5.8 whatever the rail width; h-11 min-h-11 is the floor.
  • CSS tooltips cannot escape an ancestor with overflow: hidden. If the rail is clipped, the tooltip is clipped — use a popover or an expanding rail instead.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Tailwind v4's generated sheet sets the floor. :focus-visible is Baseline 2022 and clip-path: inset(50%) Baseline 2021; the visually hidden label itself has no version floor worth quoting.

Techniques used in this demo

Utilitiesfirst-class utility or variant

Theme tokensset in @theme, used by a utility

Arbitraryneeds bracket syntax

HTML & JSno Tailwind involved

Search CodeFronts

Loading…