GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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 work as floating navigation bar css 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. The screen-cutting side of the dock sits in CSS Screen Transition Effects, and the menu outgrowing one row into a site map is mega menu ui patterns.

01Minimal line dock

What this macos dock css shows

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.

Minimal line dock
Open in a new tab

01 · Minimal line dock — Measured area 0.905% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

transformscaleXaria-current

Where to look in the code

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

What happens on screen

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.

Pill slide indicator
Open in a new tab

02 · Pill slide indicator — Measured area 7.554% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertytranslateXflex

How to read the code

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

What moves

Point at an icon and its name tag springs up above it.

Label tooltip
Open in a new tab

03 · Label tooltip — Measured area 10.12% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleYsteps(1, end)transform-origin

What to change

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

What you see

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.

Icon magnify wave
Open in a new tab

04 · Icon magnify wave — Measured area 9.015% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertyscalepointermove

Where to look in the code

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

What happens on screen

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.

Glassmorphism dock
Open in a new tab

05 · Glassmorphism dock — Measured area 6.322% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

backdrop-filterradial-gradienttranslateY

How to read the code

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

What moves

The background and the buttons share one color, and a single pair of shadows does all of the lifting.

Neumorphism dock
Open in a new tab

06 · Neumorphism dock — Measured area 6.16% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

box-shadowinsetaria-pressed

What to change

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

What you see

Glass fails most often on bright pages because a white badge over a white page has nothing left to blur.

Translucent blur badge
Open in a new tab

07 · Translucent blur badge — Measured area 30.672% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

backdrop-filterblurtranslateX

Where to look in the code

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

What happens on screen

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.

Launchpad grid expand
Open in a new tab

08 · Launchpad grid expand — Measured area 19.061% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertytranslatescale

How to read the code

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

What moves

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.

Vertical side rail
Open in a new tab

09 · Vertical side rail — Measured area 3.178% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYopacityaria-current

What to change

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

The bar telling you where you are on the page answers the same question from the top in Reading Progress Bar CSS. 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. Putting a bigger face on the dock is what a card from Profile Card Design Styles fills in.

Why does macos dock animation css have to push the neighbors 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.

Enter the archive password

The password is inside this article. You will find it as you read.