32 CSS Tab Designs08 / 32
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
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><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;
}.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;
}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;
}
```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
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 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.