32 CSS Tab Designs08 / 32

Pure CSSMIT licensed

Vertical Dots

Sidebar nav with a connecting timeline rule. The active item's dot fills in solid teal with a soft outer glow; inactive dots stay hollow. Vertical layout. Pure CSS.

Published

Live Demo
Try it

The code

<div class="tt28">
  <nav class="tt28n">
    <input type="radio" name="tt28" id="tt28-r1" checked />
    <label class="tt28b" for="tt28-r1"><span class="tt28dot"></span>Profile</label>
    <input type="radio" name="tt28" id="tt28-r2" />
    <label class="tt28b" for="tt28-r2"><span class="tt28dot"></span>Account</label>
    <input type="radio" name="tt28" id="tt28-r3" />
    <label class="tt28b" for="tt28-r3"><span class="tt28dot"></span>Billing</label>
    <input type="radio" name="tt28" id="tt28-r4" />
    <label class="tt28b" for="tt28-r4"><span class="tt28dot"></span>Logs</label>
  </nav>
</div>
.tt28 {
  background: #0d1117;
  padding: 18px 22px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  box-sizing: border-box;
  width: 100%;
}
/* nav padding-left = 30px → that x defines a "rail column" centered at x=15.
   Both the timeline rule and the active dot center on x=15 so they sit on
   the same vertical axis regardless of dot size or label padding. */

.tt28n {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-left: 30px;
  min-width: 0;
}

.tt28n::before {
  content: "";
  position: absolute;
  left: 14px;
  top: 14px;
  bottom: 14px;
  width: 2px;
  background: rgba(56, 189, 248, 0.18);
  border-radius: 1px;
}

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

.tt28b {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 6px 8px;
  font: 600 12px/1 ui-sans-serif, system-ui;
  color: rgba(255, 255, 255, 0.55);
  cursor: pointer;
  border-radius: 4px;
  transition: color 0.22s, background 0.22s;
  white-space: nowrap;
}

.tt28dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 1.5px solid rgba(56, 189, 248, 0.45);
  background: #0d1117;
  flex-shrink: 0;
  /* margin-left = -(button padding-left + dot half-width - line center)
       = -(8 + 6 - 15) = -(-1) ... actually: button starts at nav-padding-left
       (30px). Dot needs left edge at x=9 so center sits at x=15 (line center).
       Button content starts at x=38 (30 + 8 padding). To put dot's left edge
       at x=9, margin-left = 9 - 38 = -29px. */
  margin-left: -29px;
  transition: background 0.25s, border-color 0.25s, box-shadow 0.3s;
  position: relative;
  z-index: 1;
}

.tt28b:hover {
  color: rgba(255, 255, 255, 0.85);
  background: rgba(56, 189, 248, 0.06);
}

.tt28n input:checked + .tt28b {
  color: #7dd3fc;
}

.tt28n input:checked + .tt28b .tt28dot {
  background: #38bdf8;
  border-color: #38bdf8;
  box-shadow: 0 0 10px rgba(56, 189, 248, 0.55);
}

.tt28n input:focus-visible + .tt28b {
  outline: 1px dashed #38bdf8;
  outline-offset: 3px;
}
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: Vertical Dots
Source: https://codefronts.com/navigation/css-tabs/vertical-dots/

Sidebar nav with a connecting timeline rule. The active item's dot fills in solid teal with a soft outer glow; inactive dots stay hollow. Vertical layout. Pure CSS.
## HTML
```html
<div class="tt28">
  <nav class="tt28n">
    <input type="radio" name="tt28" id="tt28-r1" checked />
    <label class="tt28b" for="tt28-r1"><span class="tt28dot"></span>Profile</label>
    <input type="radio" name="tt28" id="tt28-r2" />
    <label class="tt28b" for="tt28-r2"><span class="tt28dot"></span>Account</label>
    <input type="radio" name="tt28" id="tt28-r3" />
    <label class="tt28b" for="tt28-r3"><span class="tt28dot"></span>Billing</label>
    <input type="radio" name="tt28" id="tt28-r4" />
    <label class="tt28b" for="tt28-r4"><span class="tt28dot"></span>Logs</label>
  </nav>
</div>
```
## CSS
```css
.tt28 {
  background: #0d1117;
  padding: 18px 22px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  min-height: 100vh;
  box-sizing: border-box;
  width: 100%;
}
/* nav padding-left = 30px → that x defines a "rail column" centered at x=15.
   Both the timeline rule and the active dot center on x=15 so they sit on
   the same vertical axis regardless of dot size or label padding. */

.tt28n {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-left: 30px;
  min-width: 0;
}

.tt28n::before {
  content: "";
  position: absolute;
  left: 14px;
  top: 14px;
  bottom: 14px;
  width: 2px;
  background: rgba(56, 189, 248, 0.18);
  border-radius: 1px;
}

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

.tt28b {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 6px 8px;
  font: 600 12px/1 ui-sans-serif, system-ui;
  color: rgba(255, 255, 255, 0.55);
  cursor: pointer;
  border-radius: 4px;
  transition: color 0.22s, background 0.22s;
  white-space: nowrap;
}

.tt28dot {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  border: 1.5px solid rgba(56, 189, 248, 0.45);
  background: #0d1117;
  flex-shrink: 0;
  /* margin-left = -(button padding-left + dot half-width - line center)
       = -(8 + 6 - 15) = -(-1) ... actually: button starts at nav-padding-left
       (30px). Dot needs left edge at x=9 so center sits at x=15 (line center).
       Button content starts at x=38 (30 + 8 padding). To put dot's left edge
       at x=9, margin-left = 9 - 38 = -29px. */
  margin-left: -29px;
  transition: background 0.25s, border-color 0.25s, box-shadow 0.3s;
  position: relative;
  z-index: 1;
}

.tt28b:hover {
  color: rgba(255, 255, 255, 0.85);
  background: rgba(56, 189, 248, 0.06);
}

.tt28n input:checked + .tt28b {
  color: #7dd3fc;
}

.tt28n input:checked + .tt28b .tt28dot {
  background: #38bdf8;
  border-color: #38bdf8;
  box-shadow: 0 0 10px rgba(56, 189, 248, 0.55);
}

.tt28n input:focus-visible + .tt28b {
  outline: 1px dashed #38bdf8;
  outline-offset: 3px;
}
```

How this works

The .tt28 demo lays out a vertical sidebar nav with a phosphor-cyan timeline rule and a filling dot indicator, the register onboarding wizards and settings sidebars borrowed from step progress. .tt28n is a flex column with gap: 8px and padding-left: 30px; that 30px carves out a rail column whose center sits at x=15, and the pseudo-element ::before paints a 2px vertical rule at left: 14px in rgba(56, 189, 248, 0.18), running from top: 14px to bottom: 14px so the line ends flush with the outer dots.

Each row is a hidden radio plus a .tt28b label. The .tt28dot span carries margin-left: -29px, which reverse-engineers the button's own 8px inner padding: the button content starts at x=38, and the dot's 12px width needs its left edge at x=9 so its center sits at x=15 on the rule. That single negative margin is what keeps the dots exactly on the axis regardless of label text length. Inactive dots are hollow with a 1.5px cyan border at rgba(56, 189, 248, 0.45) and a #0d1117 fill that punches the rule out from behind the dot.

Activation flips the state with .tt28n input:checked + .tt28b .tt28dot: the fill becomes solid #38bdf8, the border matches, and a 0 0 10px rgba(56, 189, 248, 0.55) box shadow adds the phosphor bloom. The label text warms from rgba(255, 255, 255, 0.55) to #7dd3fc. Everything is hand-coded pure CSS, copy-paste ready with no dependencies, and framework-agnostic; focus rings use a 1px dashed cyan outline offset by 3px so keyboard tab-through reads clearly against the dark #0d1117 canvas. SSR-safe because the checked state is a rendered attribute, not a runtime toggle.

Make it yours

  • Swap #38bdf8 and rgba(56, 189, 248, 0.18) for any accent pair; keep the 18% alpha on the rule and 45% on the inactive border so both stay legible without competing with the active dot.
  • Add or remove rows without touching the geometry; the rule spans from top: 14px to bottom: 14px, so it always ends inside the first and last dots as the list grows.
  • Widen the rail by editing padding-left: 30px and updating the dot's margin-left to rail/2 - button-padding - dot-radius; the source comment walks the arithmetic if you need larger dots.
  • Convert to light mode by replacing #0d1117 with #f8fafc and the dot fill with the same, so the inactive dot punches the rule through cleanly on either background.
  • Add a pulse ring by layering a second ::after pseudo-element on the active dot with a @keyframes scale-and-fade; guard it behind the existing prefers-reduced-motion media query so vestibular users get the calm state.

Gotchas — read before shipping

  • The dot's margin-left: -29px is derived from the nav's 30px padding and the button's 8px inner padding; if you change either value without recomputing, the dots drift off the rule and the whole thing looks broken.
  • For a real WAI-ARIA tabs pattern this needs role="tablist" with aria-orientation="vertical", role="tab" and aria-selected per item, and Up/Down arrow-key handling; the radio-input pattern here reads to assistive tech as a form group, not tabs.
  • The focus-visible outline draws inside the flex row, so on narrow containers the 3px offset can clip against neighbors; either raise the parent gap to 12px or drop the offset to 2px when packing under 220px wide.
  • Radio names collide across instances; if you place two .tt28 navs on one route, rename the name="tt28" attribute per instance or the first checked radio in the second nav will silently uncheck the first nav.
  • The 10px box shadow on the active dot bleeds outside the row's 6px vertical padding; if you constrain the nav inside overflow: hidden the glow gets clipped, so keep the container overflow visible or bump padding to 12px.
  • In React and Vue, the checked state should live in component state and drive a controlled checked prop; native uncontrolled radios inside a re-rendered component tree lose their checked state on parent updates, causing the indicator to snap back to the first item.

Browser support

ChromeSafariFirefoxEdge
88+14+78+88+

Sibling combinator, box-shadow, and focus-visible are the load-bearing features. focus-visible needs Safari 15.4 and Firefox 85 for the modern behavior; earlier versions show a permanent outline after click, which stays functional.

Techniques used in this demo

Search CodeFronts

Loading…