Mac Dock CSS — 9 Floating Dock Styles, No Library
Mac dock CSS is the plain-CSS way to rebuild that floating strip of icons at a screen edge: a short bar, a few icon buttons, and one mark for where you are.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Minimal line dock
- 02 Pill slide indicator
- 03 Label tooltip
- 04 Icon magnify wave
- 05 Glassmorphism dock
- 06 Neumorphism dock
- 07 Translucent blur badge
- 08 Launchpad grid expand
- 09 Vertical side rail
The nine are ordered by how much room the mark takes, not by popularity. In the first three, what is added over the icon is a line, a surface, and a word: an underline growing out of its own center (01), a pill filling a whole slot (02), and a name tag rising above the icon (03). From the fourth demo on, the size of the icon itself becomes the mark, and from there the room the neighbors give up has to be worked out too. Demos 05 and 06 sit together because they change the material of the bar rather than the shape of the mark — frosted glass (05) leans on backdrop-filter while the soft shadow (06) rests on a single pair of box-shadow values, so the two aim at the same spot and leave opposite impressions. The seventh asks whether glass still looks like glass over a bright page, the eighth opens the dock out into a panel, and the last stands the whole thing on end. All nine run with no library, and the ones that carry a selected state keep it in an attribute such as aria-current or aria-pressed rather than in a class.
01Minimal line dock
Five icons sit inside a thin outlined bar, and the underline beneath the selected one grows outward from its center while the previous underline folds back at the same pace. The underline never changes width — only scaleX travels from 0 to 1 — so the row never has to be laid out again.
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transition: transform $duration $easing;
}
.mn__btn.on .mn__line { transform: scaleX(1); }
02Pill slide indicator
The pill filling the active slot slides sideways one stride at a time while the icons hold their places. A stride is a fixed 56px: a 48px button plus an 8px gap. That fixed number is why every button gets flex: 0 0 auto and a width of its own instead of an equal share of the row.
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl__pill {
position: absolute;
top: $sp-2;
left: $sp-2;
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--idx) * 56px));
transition: transform $duration $easing;
}
.pl__btn {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 48px;
}
03Label tooltip
Point at an icon and its name tag springs up above it. The hinge is the bottom edge of the tag — that is what transform-origin: bottom center sets — and on the way out the tag does not fade at all: steps(1, end) cuts it in a single frame.
.tt__tip {
position: absolute;
bottom: calc(100% + #{$sp-2});
left: 50%;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $color;
color: $stage-yellow;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform $duration $easing, opacity $dur-instant steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
04Icon magnify wave
The icon nearest the pointer swells the most and the neighbors step down with distance, on a curve of 1 + 0.8 * t * t, where t reaches zero once the pointer is 90px from that icon's center. The hard part is not the scale but the shove: each icon's extra width is added up from the left and handed back as a shift, and half of the total growth is subtracted at the end so the row keeps its center.
var W = 44, RANGE = 90, MAX = 0.8;
function wave(x) {
var s = btns.map(function (b) {
var c = b.offsetLeft + W / 2;
var t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
var grown = s.map(function (v) { return (v - 1) * W; });
var total = grown.reduce(function (a, b) { return a + b; }, 0);
var before = 0;
btns.forEach(function (b, i) {
b.style.setProperty('--s', s[i].toFixed(3));
b.style.setProperty('--x', (before + grown[i] / 2 - total / 2).toFixed(1) + 'px');
before += grown[i];
});
}
05Glassmorphism dock
Two round color blobs, one lilac and one sky blue, are laid behind the frosted bar. Glass reads as glass only when something sits behind it, so the blobs are material rather than decoration. The active pill glides along the bar, and the chosen icon lifts by 3px.
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, .95), rgba(185, 160, 255, 0) 70%);
}
.gl__blob--b {
bottom: -48px;
right: -16px;
background: radial-gradient(circle, rgba(110, 200, 255, .9), rgba(110, 200, 255, 0) 70%);
}
.gl__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border: 1px solid rgba(255, 255, 255, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
backdrop-filter: blur(12px) saturate(1.6);
box-shadow: 0 12px 28px rgba(23, 20, 26, .4);
}
.gl__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }
06Neumorphism dock
The background and the buttons share one color, and a single pair of shadows does all of the lifting. The pressed state does not draw a second shadow; it flips that same pair inward with inset, and aria-pressed is what holds the state.
.nu__btn {
flex: 0 0 auto;
width: 48px;
height: 48px;
border-radius: 50%;
background: $bg;
color: rgba(60, 54, 84, .6);
cursor: pointer;
box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light;
transition: box-shadow $duration $easing, color $duration $easing;
}
.nu__btn[aria-pressed="true"] {
color: $color;
box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}
07Translucent blur badge
Glass fails most often on bright pages because a white badge over a white page has nothing left to blur. So three color bands travel beneath the badge, and each one runs to the far side and comes back along the same path, sparing the viewer the snap a one-way loop makes when it rewinds.
.bb__band--1 { left: -80px; background: $color; }
.bb__band--2 { left: -80px; background: $subject-mint; }
.bb__band--3 { left: -80px; background: $subject-sky; }
.bb__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border: 1px solid rgba(255, 255, 255, .7);
border-radius: $r-pill;
background: rgba(255, 255, 255, .55);
backdrop-filter: blur(8px);
box-shadow: 0 10px 22px rgba(23, 20, 26, .12);
}
.is-demo .bb__band--2 { animation-delay: -.22s; }
.is-demo .bb__band--3 { animation-delay: -.44s; }
@keyframes bb-drift {
0% { transform: translateX(0); }
50% { transform: translateX(420px); }
100% { transform: translateX(0); }
}
08Launchpad grid expand
A one-row dock opens out into a seven-slot grid. The panel is the same size folded or open and only the icons travel, so whatever sits below it never jumps. Position and scale live in three registered properties, which is what gives the browser numbers to interpolate between the slots.
.lp__btn {
--x: 0px;
--y: 0px;
--s: 1;
position: absolute;
top: 50%;
left: 50%;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate $duration $easing, scale $duration $easing, opacity $dur-instant $easing;
}
.lp__btn:nth-child(4) { --x: -52px; --y: 0px; }
.lp__btn:nth-child(7) { --x: 0px; --y: 44px; }
.lp__btn--more { --s: 0; opacity: 0; pointer-events: none; }
.lp.open .lp__btn--more { --s: 1; opacity: 1; pointer-events: auto; }
.lp.open .lp__btn:nth-child(1) { --y: -44px; }
@keyframes lp-grow {
0%, 36% { --s: 0; opacity: 0; }
36.01% { opacity: 1; }
44%, 60% { --s: 1; opacity: 1; }
71.99% { opacity: 1; }
72%, 100% { --s: 0; opacity: 0; }
}
09Vertical side rail
This one stands the dock up against the left edge of the screen. The active dot steps down 36px per row, which is a 32px button plus a 4px gap, and the name tags slide out to the right of the icons in turn. The vertical centering and that slide are both transform, so they have to sit in one declaration in order, or the second wipes out the first.
.sr__dot {
position: absolute;
top: 20px;
left: 2px;
width: 3px;
height: 16px;
border-radius: $r-pill;
background: $color;
transform: translateY(calc(var(--row) * 36px));
transition: transform $duration $easing;
}
.sr__label {
position: absolute;
top: 50%;
left: calc(100% + #{$sp-1});
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform $duration $easing, opacity $duration $easing;
}
.sr__btn:hover .sr__label,
.sr__btn:focus-visible .sr__label { opacity: 1; transform: translateY(-50%) translateX(0); }
Where it breaks — the trap
Across all nine, the part rebuilt most often was never the animation itself but the instant two elements cross the same spot. Demo 08 originally launched its four hidden icons out of the middle of the dock toward their own slots. On paper that looked fine; the poster frame disagreed, catching a person icon sitting half on top of a magnifier. The route the first three take up to the top row and the route the hidden four fly in on are one and the same, and the two groups were crossing it at the same moment rather than one after the other. Parking the hidden four in their own empty slots at scale 0 from the start, then letting them grow only once the first three had arrived, ended the overlap.
Demo 03 was the same fault in a different disguise. Fading the name tags out meant that while the pointer traveled to the next icon, two half-transparent tags stacked up and neither one could be read. Cutting the exit to a single frame with steps(1, end) removed that window outright. Demo 06 was reworked twice for an unrelated reason: its background, buttons, and shadows all belong to one color family, so the pressed dent barely registered. Deepening the dark side of the shadow and darkening the pressed color raised the average difference across the changed pixels from 19.5 to 22.8. Not one of the three trips an automated check; each shows up only to a human eye, as an odd frame now and then.
| Symptom | Actual cause | Where to fix it |
|---|---|---|
| A frame catches two icons overlapping | Two groups cross one path at the same moment | Grow one group in place and split the timing |
| Two name tags stack up half-transparent | The exit is a fade | Cut opacity with steps(1, end) |
| The active mark stands beside the icon instead of under it | The buttons divide the row, so the rendered width differs from the fixed stride | flex: 0 0 auto plus a fixed width |
| The mark hops between slots with nothing in between | The numeric property was never registered | Declare its syntax through @property |
Every row above is already patched in the nine sources packed into the zip, which the archive password wd6vwhyu opens, and the vanilla build and the React build read the same three variables.
Accessibility (reduced-motion)
Six of the nine carry a selected state, and all six keep it in an attribute instead of a class; the other three (03, 04, and 07) have no selection at all and answer only while a pointer rests on them. Demos 01, 02, 05, and 09 use aria-current="page", demo 06 uses aria-pressed, and the toggle in demo 08 carries aria-expanded alongside aria-controls. A screen reader then announces the current choice, and in demos 02, 05, 06, and 09 the CSS paints from that same attribute selector, so what is shown and what is announced cannot drift apart — only demo 01 moves an .on class alongside the attribute. The tag in demo 03 is aria-hidden="true", with a visually hidden copy of the text placed inside the button instead, so the same word is never read twice. Icon-only buttons carry an aria-label, though in demos 03 and 09 the text inside the button serves as that name instead. Every button keeps its :focus-visible outline so that keyboard users can see where they are, and the icons are inline Phosphor duotone SVGs, which means no icon font and no CDN request at all. Under prefers-reduced-motion: reduce, the autoplay loops, the glides, and the springs stop, while the active mark itself stays, because which slot you are on is information rather than decoration. How a screen reader reports the current item is documented in the MDN aria-current reference.
Parts that pair well with these are gathered in the hover category and the landing page category.
FAQ
Why does a fixed pixel stride survive a narrow screen?
It survives as long as the buttons are not fluid, because the stride depends on button width rather than on screen width. Moving a pill with translateX(calc(var(--idx) * 56px)) the way demo 02 does requires buttons set to flex: 0 0 auto at a fixed width, and then one slot stays 56px however narrow the page gets. Hand them flex: 1 1 0 instead and the rendered width changes with every layout, so the pill ends up straddling the seam; to shrink the dock, move the button width, the gap, and the stride together inside a media query.
Why do the neighbors have to be pushed when an icon grows?
Because scale changes only what is drawn, not the space the element takes up, so an icon scaled to 1.8 spills over the icons on either side of it. That is why demo 04 adds up each icon's extra width from the left and hands it back as a shift. Subtracting half of the total growth at the very end is what keeps the whole row from drifting to the right.
Why does my frosted dock look flat on some backgrounds?
backdrop-filter blurs whatever lies behind it, so a flat background leaves it nothing to work with. That is why demo 05 lays two color blobs behind the bar and demo 07 sends color bands under the badge. On a plain white page, a very light shadow with a 1px border floats better than glass ever will.