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

CSS Bottom Navigation Bar: 9 Transition Patterns

CSS Bottom Navigation Bar: One of nine slides the highlight to the new tab as selection changes, one grows a badge briefly as its count rises, and one moves

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine reaches out to a server, writes to localStorage, or calls Math.random anywhere in its script — each is a small, self-contained bottom tab bar built around one state value (which tab is selected, how long a press has lasted, which way the page just scrolled, how tall the device's safe-area inset is) and a short list of constants that decide exactly how a change in that value looks on screen. Because the scope stays this narrow, naming which value actually moves — a selection index, a count, a scroll direction, or an inset in pixels — is enough to tell which of these nine bottom navigation bar examples fits a given screen before any of the code gets opened.

Anatomy of nine patterns — which value changes and when

All nine run their own requestAnimationFrame loop, and none of them ever calls Math.random: every one steps forward from a real interaction rather than a randomized script, so the same demo plays out the same way on every visit. Several also compute a real spring or decay every frame rather than leaning on a CSS transition alone: item 01's springTo() carries both the indicator's position and its width toward the active tab under one shared SPRING_K 210 / SPRING_C 19 pair, item 03's bumpScale() shapes the badge's brief pop through a Math.exp-based BUMP_ZETA 4.8 decay multiplied by a BUMP_FREQ cosine, item 04's render() turns a single liftT value into height, scale, and a SHADOW_BLUR_GAIN 20 shadow all at once, item 05's onScrollFrame() derives a real velocity from the scrollTop delta and compares it against asymmetric VEL_HIDE 90 / VEL_SHOW 40 thresholds, item 06's placeActions() runs actual trigonometry over its FAN_ANGLES array to place three buttons around a real FAN_R radius, item 07's drag carries a separate settle spring (SPRING_K 240) that pulls the indicator to the nearest tab once a pointer lifts, and item 08's render() re-derives the bar's total height from a live inset value eased by LERP_RATE 10 every frame. Item 02 keeps its exponential smoothing to a single FILL_RATE value swapping between two tabs, and item 09 sets the comparison up on purpose: its normal-side panel eases through DURATION_MS 320 while its reduced-motion partner assigns the same target directly, with no easing step at all. Every listener answers a real interaction and none comes from the autoplay script: items 02, 04, and 09 answer a plain click, items 03 and 08 pair that click with a keydown for Enter or Space, items 01 and 05 add a resize listener alongside click, item 06 wires click, pointerdown, and resize together, and item 07 is the outlier, adding pointermove, pointerup, and pointercancel to click, pointerdown, and resize so a full drag gesture registers the same way a tap does. All nine start in an isDemo autoplay state that cycles on its own until the first click, pointerdown, or keydown calls human(), which strips the is-demo class and stops the scripted cycle for good; each of items 01 through 05 also checks matchMedia('(prefers-reduced-motion: reduce)') once at load and skips starting its loop entirely when it matches, while items 06 through 09 only use that same flag to gate their own autoplay cycle — 06 through 08 so a real press, drag, or toggle still answers in real time no matter what the system setting says, and 09 because comparing what reduced motion changes is its whole reason to exist.

Code per pattern

01Active-indicator slide-morph toggle — when the selected tab changes, the highlight's position and width values shift together from the old spot to the new one

The moment the selection value changes, the highlight's horizontal position and width values move together to the new tab's spot. A mobile bottom navigation bar css pattern like this suits a phone screen switching between bottom tabs, since the shared SPRING_K/SPRING_C pair lets the same spring narrate both a short hop and a long cross-bar jump without a separate rule for each distance.

Active-indicator slide-morph toggle — when the selected tab changes, the highlight's position and width values shift together from the old spot to the new one
Open in a new tab

01 · Active-indicator slide-morph toggle — when the selected tab changes, the highlight's position and width values shift together from the old spot to the new one — Measured area 6.096% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_KtranslateXwidth
  function springTo(pos, vel, target, dt) {
    var acc = (target - pos) * SPRING_K - vel * SPRING_C;
    vel += acc * dt;
    pos += vel * dt;
    return [pos, vel];
  }

  function render() {
    indicator.style.transform = 'translateX(' + posX.toFixed(2) + 'px)';
    indicator.style.width = posW.toFixed(2) + 'px';
  }

What the numbers say

Item 01 fills 6.10% of the frame cumulatively at a 32.0 average intensity while moving in 17 of 23 sampled frames, because springTo() recomputes both posX and posW from the same SPRING_K 210 / SPRING_C 19 pair every frame the spring hasn't settled, so the indicator's slide and its resize always finish on the same beat instead of two easings drifting apart.

02Icon fill-swap toggle — when the selected tab changes, that spot's fill-amount value switches from low to full

The instant the selection value turns on, that spot's fill-amount value rises to its maximum while the other spots' values drop back down. An icon-centric bottom navigation suits this pattern, since the fill and outline read as one continuous handoff rather than two icons blinking independently.

Icon fill-swap toggle — when the selected tab changes, that spot's fill-amount value switches from low to full
Open in a new tab

02 · Icon fill-swap toggle — when the selected tab changes, that spot's fill-amount value switches from low to full — Measured area 1.267% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

FILL_RATEPULSE_ZETAscale
  function loop(ts) {
    if (!lastTs) { lastTs = ts; cycleStart = ts; }
    var dt = ts - lastTs;
    lastTs = ts;

    if (isDemo) {
      var elapsed = ts - cycleStart;
      var idx = Math.floor(elapsed / HOLD_MS) % TAB_COUNT;
      if (idx !== activeIdx) setActive(idx, ts);
    }

    for (var i = 0; i < TAB_COUNT; i++) {
      var target = i === activeIdx ? 1 : 0;
      fillAmt[i] += (target - fillAmt[i]) * (1 - Math.exp(-FILL_RATE * dt));
    }

    render(ts);
    requestAnimationFrame(loop);
  }

What the numbers say

Item 02 fills 1.27% of the frame cumulatively at a 56.3 average intensity while moving in 14 of 23 sampled frames, because only the fillAmt array changes — one entry easing up toward 1 while the rest ease down toward 0 through the same exponential FILL_RATE, so the icon swap stays a small, contained shift even though it fires on every tab change.

03Badge count-bump toggle — each time a notification count value rises, that display's size value grows once then settles back

The moment the count value rises, that display's size value briefly grows larger, then settles back to its original size. A tab showing an unread notification count suits this pattern, since the bump itself becomes the only cue a visitor gets that the count just changed, without needing to re-read the number.

Badge count-bump toggle — each time a notification count value rises, that display's size value grows once then settles back
Open in a new tab

03 · Badge count-bump toggle — each time a notification count value rises, that display's size value grows once then settles back — Measured area 0.217% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

BUMP_ZETAtranslateYscale
  function bumpScale(t) {
    // calc layer: badge bump — damped cosine oscillation
    if (t < 0 || t > BUMP_DUR) return 1;
    var amp = Math.exp(-BUMP_ZETA * (t / BUMP_DUR));
    return 1 + amp * (1 - Math.cos(BUMP_FREQ * t)) * 0.5;
  }

  function bump(ts) {
    bumpStart = ts;
  }

  function increment(ts) {
    count = (count + 1) % 10;
    badge.style.display = 'inline-flex';
    bump(ts);
  }

  function render(ts) {
    var s = bumpScale(ts - bumpStart);
    badge.style.transform = 'scale(' + s.toFixed(3) + ')';
    strip.style.transform = 'translateY(' + (-pos).toFixed(2) + 'px)';
  }

What the numbers say

Item 03 fills 0.22% of the frame cumulatively at a 97.3 average intensity while moving in 20 of 23 sampled frames, because bumpScale() only lifts the badge for the brief BUMP_DUR 320ms window right after increment() fires, driven by a BUMP_ZETA 4.8 decay multiplied against a BUMP_FREQ cosine — a short, high-intensity flick rather than a sustained shift.

04Center-lift scale toggle — when the middle tab's selection value turns on, only that spot's height value grows above the background

The instant the center spot's selection value turns on, its height and size values grow together, standing out above the other spots. A bottom navigation with a primary action in the middle suits this pattern, since one shared liftT value moving four visual properties in lockstep reads as a single physical lift rather than four separate rules drifting apart.

Center-lift scale toggle — when the middle tab's selection value turns on, only that spot's height value grows above the background
Open in a new tab

04 · Center-lift scale toggle — when the middle tab's selection value turns on, only that spot's height value grows above the background — Measured area 2.599% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

LIFT_PXSHADOW_BLUR_GAINtranslateY
  function render() {
    // calc layer: physical shadow approximation — the larger the lift height (liftT), the bigger blur/y-offset grow and the fainter alpha gets
    var ty = -(LIFT_PX * liftT);
    var scale = 1 + (SCALE_MAX - 1) * liftT;
    var blur = SHADOW_BLUR_BASE + SHADOW_BLUR_GAIN * liftT;
    var y = SHADOW_Y_BASE + SHADOW_Y_GAIN * liftT;
    var alpha = Math.max(0.08, SHADOW_ALPHA_BASE + SHADOW_ALPHA_GAIN * liftT);
    center.style.transform = 'translateY(' + ty.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
    center.style.boxShadow = '0 ' + y.toFixed(1) + 'px ' + blur.toFixed(1) + 'px rgba(10, 20, 40,' + alpha.toFixed(3) + ')';
  }

What the numbers say

Item 04 fills 2.60% of the frame cumulatively at a 116.0 average intensity while moving in 12 of 23 sampled frames, because liftT drives translateY, scale, and the box-shadow's blur, y-offset, and alpha together through a single SHADOW_BLUR_GAIN 20 term, so the center tab reads as physically closer rather than just visually bigger.

05Scroll-direction hide-reveal toggle — when the scroll-direction value is downward the whole bar hides offscreen, and upward brings it back

The moment the scroll-direction value changes, the bar's vertical position value moves between offscreen and its resting spot. A screen that wants to show content full-width suits this pattern, since asymmetric thresholds (VEL_HIDE 90 above VEL_SHOW 40) make the bar reluctant to hide but quick to return.

Scroll-direction hide-reveal toggle — when the scroll-direction value is downward the whole bar hides offscreen, and upward brings it back
Open in a new tab

05 · Scroll-direction hide-reveal toggle — when the scroll-direction value is downward the whole bar hides offscreen, and upward brings it back — Measured area 39.533% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

VEL_HIDELERP_RATEtranslateY
  function onScrollFrame(scrollTop, dtSec) {
    // calc layer: derives velocity (px/s) from the real scrollTop delta and compares it against the asymmetric thresholds
    if (dtSec <= 0) return;
    var delta = scrollTop - lastScrollTop;
    lastScrollTop = scrollTop;
    var vel = delta / dtSec;
    if (scrollTop <= 2) {
      hiddenTarget = 0;
    } else if (vel > VEL_HIDE) {
      hiddenTarget = 1;
    } else if (vel < -VEL_SHOW) {
      hiddenTarget = 0;
    }
  }

  function render(dtSec) {
    navY += (hiddenTarget * BAR_H - navY) * (1 - Math.exp(-LERP_RATE * dtSec));
    track.style.transform = 'translateY(' + navY.toFixed(2) + 'px)';
  }

What the numbers say

Item 05 fills 39.53% of the frame cumulatively at a 30.5 average intensity while moving in all 23 of 23 sampled frames, because onScrollFrame() recomputes a real velocity from the scrollTop delta on every frame regardless of whether hiddenTarget actually flips that frame, and render()'s LERP_RATE 14 easing keeps navY adjusting for a few frames after every flip — the widest, steadiest motion of the nine.

06Long-press reveal toggle — once a press-duration value passes a threshold, that spot's expand-amount value grows and extra spots appear above it

The instant the press-duration value passes the threshold, the expand-amount value above that spot switches from zero to its maximum. A navigation hiding sub-options behind each tab suits this pattern, since the ring's own HOLD_MS 650 fill time doubles as a visible cancel window before the fan menu commits.

Long-press reveal toggle — once a press-duration value passes a threshold, that spot's expand-amount value grows and extra spots appear above it
Open in a new tab

06 · Long-press reveal toggle — once a press-duration value passes a threshold, that spot's expand-amount value grows and extra spots appear above it — Measured area 3.297% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

FAN_ANGLESRING_CIRCstroke-dashoffset
  function placeActions() {
    // calc layer: converts each angle in FAN_ANGLES to coordinates around the FAB's center using real trigonometry
    var fabRect = fab.getBoundingClientRect();
    var phoneRect = fab.closest('.lp__phone').getBoundingClientRect();
    var cx = fabRect.left + fabRect.width / 2 - phoneRect.left;
    var cy = fabRect.top + fabRect.height / 2 - phoneRect.top;
    actions.forEach(function (btn, i) {
      var rad = FAN_ANGLES[i] * Math.PI / 180;
      var x = cx + FAN_R * Math.cos(rad);
      var y = cy + FAN_R * Math.sin(rad);
      btn.style.left = x.toFixed(1) + 'px';
      btn.style.top = y.toFixed(1) + 'px';
    });
  }

What the numbers say

Item 06 fills 3.30% of the frame cumulatively at a 74.2 average intensity while moving in 16 of 23 sampled frames, because placeActions() only recomputes each action's x/y once the ring passes its HOLD_MS threshold, converting the FAN_ANGLES array (-150, -90, -30 degrees) into coordinates around a real FAN_R 62px radius — a burst of trigonometry rather than a continuous background cost.

07Swipe-follow toggle — while a horizontal drag-distance value grows, the highlight's position value follows toward the side tab by that same ratio

While the drag-distance value grows, the highlight's position value moves toward the side tab by that same proportion. A mobile navigation that changes tabs on a horizontal swipe suits this pattern, since the indicator tracks the drag in real time instead of jumping only after the finger lifts.

Swipe-follow toggle — while a horizontal drag-distance value grows, the highlight's position value follows toward the side tab by that same ratio
Open in a new tab

07 · Swipe-follow toggle — while a horizontal drag-distance value grows, the highlight's position value follows toward the side tab by that same ratio — Measured area 5.814% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_KDRAG_MStranslateX
  function onPointerDown(e) {
    human();
    dragging = true;
    settling = false;
    dragStartClientX = e.clientX;
    dragStartX = posX;
    track.setPointerCapture && e.pointerId != null && track.setPointerCapture(e.pointerId);
  }
  function onPointerMove(e) {
    if (!dragging) return;
    var dx = e.clientX - dragStartClientX;
    posX = Math.max(0, Math.min(maxX, dragStartX + dx));
    indicator.style.transform = 'translateX(' + posX.toFixed(1) + 'px)';
  }
  function onPointerUp() {
    if (!dragging) return;
    dragging = false;
    var idx = nearestIndex(posX + indW / 2);
    setActive(idx);
    settling = true;
    settleTarget = geo[idx].left;
    vel = 0;
  }

What the numbers say

Item 07 fills 5.81% of the frame cumulatively at a 141.6 average intensity — the highest intensity of the nine — while moving in 10 of 23 sampled frames, because posX only changes while a pointer is actually down, or while the settle spring (SPRING_K 240) is still pulling it toward geo[idx].left afterward, so the ten moved frames concentrate tightly inside the drag-and-settle window instead of spreading evenly across the sample.

08Inset-resize toggle — when the bottom-inset value grows, the bar's overall height and inner-padding values grow together by that same difference

The moment the bottom-inset value grows, the bar's overall height value and inner-padding value grow together by that same amount. A navigation adapting to different device bottom insets suits this pattern, since the same LERP_RATE 10 that eases the bar's height also eases the indicator underneath it, so neither one snaps ahead of the other.

Inset-resize toggle — when the bottom-inset value grows, the bar's overall height and inner-padding values grow together by that same difference
Open in a new tab

08 · Inset-resize toggle — when the bottom-inset value grows, the bar's overall height and inner-padding values grow together by that same difference — Measured area 22.792% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

INSET_ONBASE_HmatchMedia
  function render() {
    // calc layer: total bar height is BASE_H (content height) plus the live inset value, recomputed every frame
    var totalH = BASE_H + insetPx;
    nav.style.height = totalH.toFixed(1) + 'px';
    inset.style.height = insetPx.toFixed(1) + 'px';
    var t = insetPx / INSET_ON;
    indicator.style.opacity = t.toFixed(3);
    indicator.style.transform = 'translateY(' + ((1 - t) * 6).toFixed(1) + 'px) scaleX(' + (0.6 + 0.4 * t).toFixed(3) + ')';
    readout.textContent = 'Inset ' + Math.round(insetPx) + 'px · bar height ' + Math.round(totalH) + 'px';
  }

  toggle.addEventListener('click', function () {
    human();
    manualOn = !manualOn;
    insetTarget = manualOn ? INSET_ON : 0;
    toggle.textContent = manualOn ? 'Switch to home-button device' : 'Switch to home-indicator device';
  });

What the numbers say

Item 08 fills 22.79% of the frame cumulatively at an 89.8 average intensity while moving in 17 of 23 sampled frames, because render() re-derives totalH from BASE_H 58 plus the live insetPx every frame the inset is still easing toward INSET_ON 34 or back to 0, so the bar's own height keeps shifting for as long as that LERP_RATE catch-up lasts.

09Reduced-motion instant toggle — with reduced motion on, every transition from the prior eight bars switches straight to its target value

When the reduced-motion flag is on, all eight prior spots switch straight to their target value with no in-between steps. A screen for users with the accessibility setting on suits this pattern, since comparing two panels side by side is the clearest way to show what the eight prior toggles give up under reduced motion.

Reduced-motion instant toggle — with reduced motion on, every transition from the prior eight bars switches straight to its target value
Open in a new tab

09 · Reduced-motion instant toggle — with reduced motion on, every transition from the prior eight bars switches straight to its target value — Measured area 5.072% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

DURATION_MSCYCLE_MSmatchMedia
  function goToA(idx, ts) {
    activeA = idx;
    tabsA.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
    fromXA = currentXA(ts);
    toXA = geoA[idx].left;
    transStartA = ts;
    transitioningA = true;
  }
  function currentXA(ts) {
    if (!transitioningA) return toXA;
    var t = Math.min(1, (ts - transStartA) / DURATION_MS);
    return fromXA + (toXA - fromXA) * easeOutCubic(t);
  }

What the numbers say

Item 09 fills 5.07% of the frame cumulatively at a 133.8 average intensity while moving in 12 of 23 sampled frames, because currentXA() eases the normal-side panel toward toXA through DURATION_MS 320 and an easeOutCubic curve, while its reduced-motion partner assigns the same target directly — the moved-frame count here comes entirely from that one easing side, not from its instant comparison partner.

Where values move position and size — selection, count, scroll-direction basis

Item 01's springTo() and its SPRING_K 210 / SPRING_C 19 pair are what let one shared spring drive both position and width at once: every frame the spring hasn't settled, the same acceleration term feeds both posX and posW, so the indicator's slide and its resize always finish on the same beat instead of two easings drifting apart.

Item 06's placeActions() and its FAN_ANGLES array are what turn a flat list of extra actions into a real fan: each angle in [-150, -90, -30] degrees runs through actual sine and cosine against a FAN_R 62px radius, so the three buttons land in a genuine arc around the FAB rather than a straight row that only looks curved.

Item 08's render() and its INSET_ON 34 / BASE_H 58 pair are what let the bar's own height track a live safe-area inset instead of sitting at one fixed constant: totalH is recomputed from BASE_H plus the eased inset every frame, so a taller inset genuinely grows the bar rather than just padding its content.

Looking past this single post, 9 CSS Tab Switch Animations — No Library, Pure CSS works a different axis — nine ways a tab bar stages its own switch animation, rather than nine ways a bottom bar's state value moves. Mac Dock CSS — 9 Floating Dock Styles, No Library keeps the single-row-of-destinations idea close to this post's own, but swaps a fixed bottom bar for a dock that floats and reshapes on hover instead of on a state change. 9 mega menu ui patterns, link list to site map drops the single-row constraint entirely and organizes a whole site map into one open panel instead of nine value-driven bars. Weighing these three against the nine bottom navigation bar patterns above is the quickest way to tell which axis actually matters for a given screen, and zwxer7f5 is what unlocks the zip sitting just below.

Where it breaks — rapid switching, inset values, reduced motion

None of the nine reads a value from outside itself — every change comes from the demo's own click, pointer, or keyboard handler, which is what keeps a bottom navigation bar html css js build this deterministic to test even though the motion underneath runs through real spring, decay, or trigonometry math.

Rapid switching before the spring settles

Item 01's springTo() never blocks a second selection change while the first is still catching up — a visitor tapping through all four tabs in quick succession restarts the acceleration term from wherever posX/posW currently sit, so the indicator can visibly head toward a third tab before it ever reaches the second one it was aimed at a moment earlier.

An inset that never resolves to a whole pixel

Item 08's INSET_ON 34 target is a demo assumption, not a measured value — a real device's safe-area inset can land anywhere in between, and because LERP_RATE 10 keeps insetPx easing rather than snapping, totalH and the indicator's scaleX sit at a fractional, in-between height for as long as the inset keeps changing, which is normal here but worth rounding deliberately in a production build rather than trusting the raw float.

Reduced motion

Items 01 through 05 each check matchMedia('(prefers-reduced-motion: reduce)') once at load and skip starting their requestAnimationFrame loop entirely when it matches — a single static frame draws at the resting state and the bar simply stops there. Items 06 through 09 only use that same flag to gate their own autoplay cycle (isDemo && !reduce); each of their tick() loops keeps running unconditionally either way, because a visitor's own press, drag, or toggle still needs to answer in real time no matter what the system setting says — and for item 09 specifically, comparing the two panels side by side only works if both of them can still move.

Three variants and accessibility

Grow the center-lift scale (SCALE_MAX 1.14 to 1.28) — raising the scale from 1.14 to 1.28 makes the center tab pop up much more dramatically the instant it's selected, reinforcing that it's the primary action.

Lengthen the long-press threshold (HOLD_MS 650 to 1200) — stretching the threshold from 650 to 1200ms makes the ring take almost twice as long to fill, cutting down on menus opening from accidental presses.

Slow down the inset response (LERP_RATE 10 to 4) — lowering the smoothing rate from 10 to 4 makes the bar height stretch toward its target far more leisurely, so the inset change feels less abrupt.

Variant What changes What it doesn't touch
Grow the center-lift scale (SCALE_MAX 1.14→1.28) Item 04's center-tab pop height only LIFT_PX, SHADOW_BLUR_GAIN, HOLD_MS
Lengthen the long-press threshold (HOLD_MS 650→1200) Item 06's ring-fill duration only FAN_ANGLES, RING_CIRC, FAN_R
Slow down the inset response (LERP_RATE 10→4) Item 08's inset-easing speed only BASE_H, INSET_ON, matchMedia

Two of the nine — items 01 and 02 — toggle a real aria-current value between 'true' and 'false' on each tab button as the selection changes, giving a screen-reader visitor the same which-tab-is-active signal a sighted visitor gets from the sliding indicator or the fill swap. The other seven don't carry that attribute, so a real integration wiring any of items 03 through 09 into an actual site navigation would need to add aria-current (or a paired tablist/tab role) itself rather than assume the demo already covers it. Every one of the nine builds its interactive controls out of real <button> elements rather than a styled <div>, and none of the nine ever sets a disabled attribute anywhere, so tab order and keyboard focus never shift with whatever phase a bar happens to be in.

FAQ

Why does a bottom navigation bar need nine different transition patterns instead of one generic bar?

Because the value driving a bottom bar rarely means the same thing twice: a selection index, a count that only rises, a scroll direction, a press duration, and a safe-area inset each call for a different transform. Naming which value is actually moving — selection, count, or inset — is what tells you which of these nine fits a given screen, before a single line of it gets copied.

Does item 06's long-press ring fill through a CSS @keyframes animation, or is it computed by hand?

The ring's fill runs on a hand-written formula, not a @keyframes rule. Every frame while a pointer is down, the ring's stroke-dashoffset is derived directly from the elapsed hold time against HOLD_MS, and RING_CIRC itself comes from 2 * Math.PI * RING_R rather than a hardcoded number, so the same math would still hold if the ring's radius ever changed.

Does this set include a top tab bar style, or are all nine anchored to the bottom?

All nine here are bottom-anchored — every one assumes a fixed bar sitting under the content, never a bar docked to the top or the side. 9 CSS Tab Switch Animations — No Library, Pure CSS, linked above, is the collection built around a tab bar that isn't tied to any one screen edge.

Sources: MDN — Using CSS transitions · MDN — prefers-reduced-motion · MDN — env() · MDN — ARIA tablist role

Enter the archive password

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