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

CSS Browser Compatibility: 9 Practical Fallbacks

Nine css browser compatibility fixes: one recalculates height as the address bar resizes, one locks input zoom on focus, one stops the background from bouncing

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

None of the nine patterns below sniff a user agent string. Each one either declares an older rule first and lets a newer rule layer over it, asks the runtime a direct yes/no question, or demonstrates two versions of the same control side by side so the difference is visible instead of theoretical. Every demo starts on its own once the page settles and switches to manual control on the first pointer, keyboard, or scroll event, and every one of the nine keeps working with prefers-reduced-motion: reduce set.

Demo

Scroll, focus, pull, and toggle each panel below — the "simulate fallback" button in every demo forces the non-supporting path on purpose, so you can compare both states without leaving the page.

Anatomy of nine patterns — which CSS closes the environment gap

Grouping the nine patterns this way is the fastest way to see what css cross browser compatibility actually costs in practice: sometimes it's a stylesheet rule, sometimes a number, and sometimes nothing more than a question asked once at runtime.

Pattern Mechanism Moved frames (of 23) Cumulative area Avg. intensity
01 Custom scrollbar stylesheet-first, layered 21 7.615% 111.3
02 Blur fallback stylesheet-first, layered 8 7.263% 46.8
03 Dynamic viewport height side-by-side threshold 14 5.978% 167.8
04 Input zoom lock side-by-side threshold 8 5.458% 115.4
05 Flex gap fallback stylesheet-first, layered 20 2.822% 145.9
06 Feature-support branch runtime question 10 21.491% 79.1
07 Overscroll lock stylesheet-first, layered 19 9.72% 106.9
08 Glyph rendering correction stylesheet-first, layered 23 3.592% 196.5
09 Custom date input runtime question 18 3.485% 131.6

Cluster A — the stylesheet declares the old rule first

Five patterns (01, 02, 05, 07, 08) write the older, always-safe rule with no condition attached, then wrap the improved rule in a feature query. Toggling "simulate fallback" doesn't fake a user agent — it strips the class that lets the feature-query block apply, so the unconditional rule underneath is exactly what's left.

Cluster B — the script asks the runtime directly

Two patterns (06, 09) skip the stylesheet condition entirely and check css browser support from script instead. One re-runs five boolean checks against the live rendering environment every time it scans; the other tests once whether a method exists on a built-in prototype and branches a click handler on that single boolean.

Cluster C — a number, not a feature test

Two patterns (03, 04) aren't feature-detected at all. Both render two versions of the same control side by side so the older behavior and the corrected behavior are visible in the same frame, and a millisecond or pixel constant controls how the corrected version behaves — not whether it appears.

Code per pattern

Each source below is a continuous slice of the demo's actual stylesheet or script, comments translated, identifiers untouched.

01Custom scrollbar — a scroll thumb with its own thickness, color, and corner radius

When the list scrolls, a lerp-smoothed dot follows the real scroll ratio along a side track, while the list itself carries a standard scrollbar declaration backed by an unprefixed pseudo-element fallback underneath it. Across 24 sampled frames the track dot moved in 21, covering 7.6% cumulative area at an average intensity of 111.3. Pointer, keyboard, and scroll input on the list all count as the first human interaction.

Custom scrollbar — a scroll thumb with its own thickness, color, and corner radius
Open in a new tab

01 · Custom scrollbar — a scroll thumb with its own thickness, color, and corner radius — Measured area 7.615% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

scrollbar-widthscrollbar-color::-webkit-scrollbar
.itemList {
  margin: 0;
  padding: $sp-2 $sp-3;
  list-style: none;
  width: 220px;
  height: 116px;
  overflow-y: scroll;
  border-radius: $r-sm;
  background: rgba($subject-cream, .06);

  // Fallback: legacy pseudo-element scrollbar — always declared; a supporting environment lets the @supports block below override it
  &::-webkit-scrollbar { width: 10px; }
  &::-webkit-scrollbar-track { background: transparent; }
  &::-webkit-scrollbar-thumb { background: $subject-blue; border-radius: $r-pill; }
}

@supports (scrollbar-width: thin) {
  .itemList:not(.force-fallback) {
    scrollbar-width: thin;
    scrollbar-color: $subject-blue-light transparent;
  }
}

02Blur fallback — a card that fills with a translucent color where the blur effect isn't applied

Opening the card fades and pops it in over a scaled overshoot, while its background either blurs the gradient behind it or, once the blur test fails, fills with a flat translucent color instead. That fallback fill only moved in 8 of 23 frames (7.3% cumulative area, average intensity 46.8), since the card's own entrance carries the motion rather than the background swap. A click on the toggle button forces the fallback path for direct comparison.

Blur fallback — a card that fills with a translucent color where the blur effect isn't applied
Open in a new tab

02 · Blur fallback — a card that fills with a translucent color where the blur effect isn't applied — Measured area 7.263% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

backdrop-filter-webkit-backdrop-filter@supports
.card {
  position: relative;
  z-index: 2;
  width: 160px;
  padding: $sp-3 $sp-4;
  border-radius: $r-card;
  opacity: 0;
  transform: scale(.86);
  transition: transform $dur-base $ease-elastic, opacity $dur-quick $ease-out;

  // Fallback: fills with a translucent solid color when there's no blur — always declared; @supports overwrites it in a supporting environment
  background: rgba($subject-ink, var(--fallback-alpha, .6));
}

@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .wrap:not(.force-fallback) .card {
    background: rgba($subject-ink, .28);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
  }
}

03Dynamic viewport height — a full-screen height that re-measures as the address bar resizes

Two phone frames share one address-bar toggle: the fixed frame's bottom button never re-measures once the bar collapses, while the second frame waits out a short debounce and then shifts up by exactly the bar's height. Across 24 frames the anchor button moved in 14 (6.0% cumulative area, average intensity 167.8). Clicking or pressing a key on the toggle both count as the first interaction.

Dynamic viewport height — a full-screen height that re-measures as the address bar resizes
Open in a new tab

03 · Dynamic viewport height — a full-screen height that re-measures as the address bar resizes — Measured area 5.978% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

100vhdvh--bar-h
  function recalc() {
    var barH = collapsed ? 0 : BAR_OPEN_PX;
    // Only the dynamic-viewport frame re-measures — the fixed frame reproduces the real bug of never recalculating
    screenDvh.style.setProperty('--bar-h', barH + 'px');
    anchorDvh.classList.add('is-settled');
  }

  function scheduleRecalc() {
    anchorDvh.classList.remove('is-settled');
    if (timer) clearTimeout(timer);
    timer = setTimeout(recalc, VH_RECALC_DEBOUNCE_MS);
  }

04Input zoom lock — a text field that focuses without zooming the whole screen

Two text fields sit side by side: one stays under the zoom threshold when focused, the other is held above it by a locked base font size, so only the first field visibly scales on focus. It moved in 8 of 23 sampled frames (5.5% cumulative area, average intensity 115.4). Focus and blur on either field, plus a click on the toggle, all mark the demo as touched.

Input zoom lock — a text field that focuses without zooming the whole screen
Open in a new tab

04 · Input zoom lock — a text field that focuses without zooming the whole screen — Measured area 5.458% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

LOCK_FONT_PXfont-sizezoomScaleFor
  function zoomScaleFor(el) {
    var css = getComputedStyle(el);
    var current = parseFloat(css.fontSize) || LOCK_FONT_PX;
    return Math.max(1, LOCK_FONT_PX / current);
  }

  function applyFocusZoom(el, on) {
    if (on) {
      var rect = el.getBoundingClientRect();
      var originX = rect.width / 2;
      var originY = rect.height / 2;
      el.style.transformOrigin = originX + 'px ' + originY + 'px';
      el.style.transform = 'scale(' + zoomScaleFor(el) + ')';
    } else {
      el.style.transform = 'scale(1)';
    }
  }

05Flex gap fallback — spacing that falls back to margins when the gap property isn't applied

Typing a tag and pressing Enter drops a new chip into a row that either receives a shared gap, or, once that property fails its test, falls back to a margin on every chip but the last. The row moved in 20 of 23 frames — 2.8% cumulative area, the smallest of the nine — at an average intensity of 145.9. Clicking a chip removes it with the same transition that added it.

Flex gap fallback — spacing that falls back to margins when the gap property isn't applied
Open in a new tab

05 · Flex gap fallback — spacing that falls back to margins when the gap property isn't applied — Measured area 2.822% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

gap@supportsmargin-right
.chipRow {
  display: flex;
  flex-wrap: wrap;
  width: 100%;
  min-height: 34px;
  margin: 0;
  padding: 0;
  list-style: none;

  // Fallback: an environment that doesn't know gap — every chip but the last gets a right margin. Always declared; the @supports block below overrides it in a supporting environment
  > .chip:not(:last-child) { margin-right: $sp-2; }
}

@supports (gap: 1rem) {
  .wrap:not(.force-fallback) .chipRow {
    gap: $sp-2;

    > .chip:not(:last-child) { margin-right: 0; }
  }
}

06Feature-support branch — a layout that checks support first, then switches its own rules

A checklist re-asks five feature questions directly at scan time and scales a summary bar to whatever ratio comes back true, so the bar's width is never hand-set. It is the largest mover by area of the nine (21.5% cumulative) even though only 10 of 23 frames actually change, since a single reveal shifts the whole list at once. A rescan button and a toggle both count as interaction.

Feature-support branch — a layout that checks support first, then switches its own rules
Open in a new tab

06 · Feature-support branch — a layout that checks support first, then switches its own rules — Measured area 21.491% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

CSS.supportsaspect-ratiocontainer-type
  // The same judgment basis as a real @supports check — CSS.supports() tests the current rendering environment directly
  var FEATURES = [
    { label: 'Flex gap', test: function () { return CSS.supports('gap', '1px'); } },
    { label: 'Aspect ratio', test: function () { return CSS.supports('aspect-ratio', '1 / 1'); } },
    { label: 'Backdrop blur', test: function () { return CSS.supports('backdrop-filter', 'blur(1px)'); } },
    { label: 'Parent selector', test: function () { return CSS.supports('selector(:has(a))'); } },
    { label: 'Container query', test: function () { return CSS.supports('container-type', 'inline-size'); } }
  ];

07Overscroll lock — a list that stops the pull without dragging the background along

Pulling a list past its own top or bottom absorbs the extra scroll into a capped pull indicator instead of letting the motion continue into the page behind it, and the same script does the absorbing whether or not the underlying property is present. It moved in 19 of 23 frames (9.7% cumulative area, average intensity 106.9) — the wheel event itself, plus the trailing scroll event, both drive the release.

Overscroll lock — a list that stops the pull without dragging the background along
Open in a new tab

07 · Overscroll lock — a list that stops the pull without dragging the background along — Measured area 9.72% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

overscroll-behavior@supportswheel
  function setPull(px) {
    pull = Math.max(0, Math.min(OVERSCROLL_LOCK_PX, px));
    pullCap.style.transform = 'scaleY(' + (pull / OVERSCROLL_LOCK_PX) + ')';
  }

  function release() {
    setPull(0);
  }

  // The real wheel listener — absorbs only the leftover delta after hitting the boundary as a pull, and stops scroll chaining
  panel.addEventListener('wheel', function (e) {
    human();
    if (e.deltaY > 0 && atBottom()) {
      e.preventDefault();
      setPull(pull + e.deltaY * 0.4);
      if (releaseTimer) clearTimeout(releaseTimer);
      releaseTimer = setTimeout(release, 220);
    } else if (e.deltaY < 0 && atTop() && pull > 0) {
      e.preventDefault();
      setPull(pull + e.deltaY * 0.4);
      if (releaseTimer) clearTimeout(releaseTimer);
      releaseTimer = setTimeout(release, 220);
    }
  }, { passive: false });

08Glyph rendering correction — evening out stroke weight that renders differently by environment

A split-screen comparison drags a boundary between two renderings of the same line of text — one exaggerated with a soft shadow, one corrected with heavier weight and tighter letter-spacing — while a pointer- or keyboard-moved handle updates the split live. It is the most consistently animated pattern of the nine, moving in 23 of 23 frames at an average intensity of 196.5, the highest of all nine. The handle accepts pointer drag, arrow keys, and a toggle click alike.

Glyph rendering correction — evening out stroke weight that renders differently by environment
Open in a new tab

08 · Glyph rendering correction — evening out stroke weight that renders differently by environment — Measured area 3.592% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

-webkit-font-smoothing-moz-osx-font-smoothing@supports
.textLayer.is-raw {
  font-weight: 400;
  letter-spacing: 0;
  text-shadow: 0 0 1.4px rgba($subject-ink, .55), 0 0 .5px rgba($subject-ink, .4);
}

// Fallback: corrects clarity with weight and letter-spacing alone even without vendor smoothing — always declared; the @supports block below is only a supplement
.textLayer.is-fixed {
  font-weight: 600;
  letter-spacing: .01em;
  text-shadow: none;
  clip-path: inset(0 calc(100% - var(--split)) 0 0);
  transition: clip-path $dur-instant $ease-out;
}

@supports (-webkit-font-smoothing: antialiased) or (font-smoothing: antialiased) {
  .wrap:not(.force-fallback) .textLayer.is-fixed {
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }
}

09Custom date input — redrawing the built-in calendar icon and field background

Choosing a date fills a ring gauge with the real day count to a reference date and relabels a countdown, while a calendar button either opens the field's own picker or, on the fallback path, simply focuses the field instead. It moved in 18 of 23 frames (3.5% cumulative area, average intensity 131.6). A change on the date field and a click on either button both count as interaction.

Custom date input — redrawing the built-in calendar icon and field background
Open in a new tab

09 · Custom date input — redrawing the built-in calendar icon and field background — Measured area 3.485% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

showPickercolor-scheme::-webkit-calendar-picker-indicator
  // In an environment that knows the standard showPicker() method, the button opens the real calendar; otherwise it only focuses the field
  var hasShowPicker = 'showPicker' in HTMLInputElement.prototype;
  pickBtn.addEventListener('click', function () {
    human();
    if (hasShowPicker && !wrap.classList.contains('force-fallback')) {
      try { dateInput.showPicker(); } catch (err) { dateInput.focus({ preventScroll: true }); }
    } else {
      dateInput.focus({ preventScroll: true });
    }
  });

Where values change the fallback — support, thresholds, and order

Six of the nine patterns branch on a plain yes/no; only two carry a number that changes what the corrected path actually does.

01 — order, not a number

The pseudo-element rule and the standard rule can both sit in the same stylesheet without conflict. There's nothing to tune — removing the class that lets the feature query apply is the entire simulation.

02 — how solid the fallback looks

FALLBACK_BG_ALPHA sets how densely the fallback card fills when the blur test fails; it doesn't decide whether the fallback triggers. The variant below raises it from 0.6 to 0.85.

03 — how soon the recalculation lands

VH_RECALC_DEBOUNCE_MS is the delay between the address bar moving and the offset being written back. The variant below drops it from 150ms to 50ms.

04 — the pixel size that disarms the zoom

LOCK_FONT_PX is the font size the locked field has to reach before the platform's own zoom threshold stops applying. Below that size, focusing still scales the field.

05 — order, not a number

The margin rule stands until the gap-aware block strips it back off; there's no numeric threshold to adjust here either, only which rule wins the cascade.

06 — nothing is cached

Every row runs its own boolean check again at the moment the panel is scanned or rescanned — no result carries over from a previous visit.

07 — how far the panel can be pulled

OVERSCROLL_LOCK_PX caps how many pixels of pull the panel absorbs before it releases. The variant below widens it from 24px to 48px.

08 — order, not a number

The corrective declaration already sits under an @supports guard, so an unsupported render keeps the exaggerated stroke only inside the side-by-side comparison, never in real body text.

09 — a single boolean

Whether the prototype exposes the picker method decides which of the two focus paths runs on a click; there's no threshold, only a branch.

Where it breaks — older environments, ordering mistakes, reduced motion

The most common way to break a css fallback isn't a missing feature test — it's writing the enhanced rule before the fallback rule in the stylesheet, so a later, more specific selector overrides the corrected declaration even in a fully supporting environment. Every pattern here keeps its unconditional rule first and layers the improvement after it, in source order, on purpose.

The nine sources ship together in one archive behind a single password, and once you're reading this deep into the post kda6g7zx is simply the next word in the paragraph — the same password unlocks all nine folders at once, so there's nothing to match up by hand.

What reduced motion leaves behind

All nine patterns cut their CSS transitions under prefers-reduced-motion: reduce, so toggles, chips, and handles jump to their end state instead of easing there. One pattern is the exception worth knowing: the scroll-track dot in pattern 01 isn't driven by a CSS transition at all — its position comes from a per-frame lerp calculation in script — so its reduced-motion branch snaps the shown value straight to the target instead of turning a transition off, because there was never a transition to turn off.

Three variants and accessibility

Variant Changed value Effect
Deepen the fallback background 02 FALLBACK_BG_ALPHA 0.6 → 0.85 Raising FALLBACK_BG_ALPHA to 0.85 fills the card background more densely in environments without the blur effect
Speed up the viewport recalculation 03 VH_RECALC_DEBOUNCE_MS 150 → 50 Lowering VH_RECALC_DEBOUNCE_MS to 50ms recalculates the height value sooner after the address bar moves
Widen the overscroll lock range 07 OVERSCROLL_LOCK_PX 24 → 48 Increasing OVERSCROLL_LOCK_PX to 48px requires a harder pull on the list before the lock releases

Every interactive control across the nine — toggles, the date field, the smoothing handle, the tag input — carries a :focus-visible outline and stays reachable by keyboard alone; toggle buttons expose their state through aria-pressed rather than color only, and the slider handle in pattern 08 carries role="slider" with live aria-valuenow. Combined with the reduced-motion branch above, none of the nine patterns depend on seeing motion to understand what changed.

FAQ

Do these patterns need a build step or a framework?

No. Every pattern is a plain stylesheet rule plus, where noted, a short script — copy the code block or the archive's source files directly into a project with no bundler step required.

How do I confirm a css fallback value is actually being used?

Use the "simulate fallback" toggle in each demo — it forces the non-supporting path by removing the class the feature query depends on, so the fallback rule you're checking is the one actually rendering, not a guess.

Why does pattern 06 show the biggest cumulative area if the check itself is quick?

Because a single scan reveals five rows and scales one bar in the same instant, the frame where that happens changes a large share of the panel at once — a big area score describes how much of the frame changed, not how long the check itself took.

Sources: the @supports feature query, the gap property, viewport-percentage length units, overscroll-behavior, and scrollbar-color.

Related reading: for a translucent-panel effect built the same layered way, see 9 CSS Glassmorphism Effects to Copy-Paste. For a calendar control that opens and closes on its own terms, see Date Picker UI: 9 Popover & Calendar Patterns. And for more ways to show a reader exactly how far they've scrolled, see 9 Reading Progress Bar CSS Patterns, No Library.

Enter the archive password

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