Skip to content

Shield classic scrollbars from the fade, per axis - #10

Merged
petekp merged 4 commits into
mainfrom
feat/scrollbar-safe-axes
Jul 31, 2026
Merged

Shield classic scrollbars from the fade, per axis#10
petekp merged 4 commits into
mainfrom
feat/scrollbar-safe-axes

Conversation

@petekp

@petekp petekp commented Jul 25, 2026

Copy link
Copy Markdown
Owner

A mask on a scroll container also masks that container's scrollbar: masking applies after the element and its descendants draw to one temporary canvas, and a visible scrolling mechanism is part of that canvas. Overlay bars composite separately and escape, which is why this is invisible on a Mac and obvious on Windows.

The strip's width cannot be derived. CSS Overflow 3 folds the reserved gutter into the padding area for background positioning, and CSS Masking defines mask boxes as the background boxes, so no isolates it; container query units measure it but resolve against an ancestor, so only descendants can read them. So fade-scrollbar-safe-* does not measure the bar, it declares it: pins via ::-webkit-scrollbar and sizes the strip from the same custom property, which makes them equal by construction and keeps them aligned under page zoom.

Added:

  • fade-scrollbar-safe-y / -x / -xy. The suffix names the SCROLL axis, following overflow-y/overflow-x -- deliberately the opposite convention from this plugin's own fade-y, which names faded edges. Only -y and -xy stabilize a gutter; -x must not, or it would reserve an inline gutter for a vertical bar the author just said does not exist and put the strip on content.
  • fade-scrollbar-width-sm|md|lg (11/15/17px) over a new --fade-scrollbar-width-* namespace. Absolute px, not the spacing ramp: a scrollbar is device chrome, not typographic rhythm. A pure setter, and inheriting, so a house style can live on an ancestor.

Fixed:

  • The strips no longer make @Property load-bearing for the whole plugin. mask-size referenced --tw-fade-scrollbar, which is only declared inside @supports selector(::-webkit-scrollbar); elsewhere its value came solely from the @Property initial value. Without @Property that reference is guaranteed-invalid, which invalidated the entire mask-size declaration at computed-value time -- all six layers fell to auto, the two 0px strips became full-size opaque layers, and the fade vanished. Every reference now carries an explicit fallback, verified by re-rendering with @Property stripped out.
  • The inline-end ramp now reaches transparent on the content edge, not inside the gutter (measured 40/255 -> 0/255 at a 60px band with a 15px bar).
  • The strip is anchored to the padding box, so it tracks the gutter through a border instead of straddling it.

Docs corrected against the code while documenting the feature: the Exports support contract excluded the feature's own two knobs, the Safari no-gutter limit understated when it fires (the static fallback pins fades on regardless of overflow on Safari 17.x/18.x), the collision warning named a Tailwind utility that does not exist while omitting scrollbar-thumb-* / -track-*, "How It Works" still described a four-layer mask, and two stale measured figures predated the 0.7.0 onset/alpha decouple.

Verification lives in build/verify-scrollbar.mjs: headed Chromium, real pixels, nine areas. Headed on purpose -- headless reserves a gutter without painting a bar, and reserves nothing at all for a horizontal one, so every assertion would pass vacuously.

petekp added 4 commits July 24, 2026 21:35
A mask on a scroll container also masks that container's scrollbar: masking
applies after the element and its descendants draw to one temporary canvas,
and a visible scrolling mechanism is part of that canvas. Overlay bars
composite separately and escape, which is why this is invisible on a Mac and
obvious on Windows.

The strip's width cannot be derived. CSS Overflow 3 folds the reserved gutter
into the padding area for background positioning, and CSS Masking defines mask
boxes as the background boxes, so no <coord-box> isolates it; container query
units measure it but resolve against an ancestor, so only descendants can read
them. So fade-scrollbar-safe-* does not measure the bar, it declares it: pins
via ::-webkit-scrollbar and sizes the strip from the same custom property, which
makes them equal by construction and keeps them aligned under page zoom.

Added:
- fade-scrollbar-safe-y / -x / -xy. The suffix names the SCROLL axis, following
  overflow-y/overflow-x -- deliberately the opposite convention from this
  plugin's own fade-y, which names faded edges. Only -y and -xy stabilize a
  gutter; -x must not, or it would reserve an inline gutter for a vertical bar
  the author just said does not exist and put the strip on content.
- fade-scrollbar-width-sm|md|lg (11/15/17px) over a new --fade-scrollbar-width-*
  namespace. Absolute px, not the spacing ramp: a scrollbar is device chrome,
  not typographic rhythm. A pure setter, and inheriting, so a house style can
  live on an ancestor.

Fixed:
- The strips no longer make @Property load-bearing for the whole plugin.
  mask-size referenced --tw-fade-scrollbar, which is only declared inside
  @supports selector(::-webkit-scrollbar); elsewhere its value came solely from
  the @Property initial value. Without @Property that reference is
  guaranteed-invalid, which invalidated the entire mask-size declaration at
  computed-value time -- all six layers fell to auto, the two 0px strips became
  full-size opaque layers, and the fade vanished. Every reference now carries an
  explicit fallback, verified by re-rendering with @Property stripped out.
- The inline-end ramp now reaches transparent on the content edge, not inside
  the gutter (measured 40/255 -> 0/255 at a 60px band with a 15px bar).
- The strip is anchored to the padding box, so it tracks the gutter through a
  border instead of straddling it.

Docs corrected against the code while documenting the feature: the Exports
support contract excluded the feature's own two knobs, the Safari no-gutter
limit understated when it fires (the static fallback pins fades on regardless
of overflow on Safari 17.x/18.x), the collision warning named a Tailwind
utility that does not exist while omitting scrollbar-thumb-* / -track-*, "How
It Works" still described a four-layer mask, and two stale measured figures
predated the 0.7.0 onset/alpha decouple.

Verification lives in build/verify-scrollbar.mjs: headed Chromium, real pixels,
nine areas. Headed on purpose -- headless reserves a gutter without painting a
bar, and reserves nothing at all for a horizontal one, so every assertion would
pass vacuously.
Without animation-timeline: scroll() the plugin takes its static fallback, which
pins every selected fade ON regardless of whether the container overflows. A
faded container with no overflow has no reserved gutter, so the strip lands on
content. Measured in WebKit: a plain `overflow-auto fade-y
fade-scrollbar-safe-y` whose content does not overflow fades its inline end to
fully transparent and then paints a hard 15px opaque band of content beyond it --
the exact inversion of what the utility is for, on markup that never mentions
fade-always.

Gate the whole opt-in on @supports (animation-timeline: scroll()), pin included.
Dropping only the strip is worse than useless: the pin is what makes the bar
classic, so pinned-but-unshielded manufactures a space-consuming bar on macOS --
a platform with no bug -- and then lets the fade dim it. Safari 17.x/18.x and
release Firefox now keep their native scrollbars instead. This costs nothing
where the feature works, since every engine that can shield a bar (Blink,
WebKit 26+) has scroll-driven animations.

Rejected on measurement: multiplying the strip extents by
max(--tw-fade-t, -b, -l, -r) so they collapse when no edge is faded. The slab
cases all have amounts pinned to 1 -- fade-always, the static fallback, or a live
cross-axis fade -- so the factor is 1 exactly when the bug fires. The only case
it changes is an opted-in container with no active fade, which is already correct
because the inline-end inset and the strip cancel each other there.

The residual on WebKit 26+ is not fixable in CSS: the gutter's existence is not
queryable, which is the same wall the strip's width ran into. An author can close
it with overflow-y: scroll, which forces the gutter in WebKit too -- measured,
15px reserved with the strip landing exactly on the track. Documented in Limits
alongside the stand-down.

Verified two ways. Section 10 rewrites the feature query in the shipped CSS so a
modern engine takes the old engine's branch, then asserts no gutter, no pin and
no strip -- and asserts the fallback still pins fades on, so the section fails
loudly rather than vacuously if that premise ever changes. Section 11 drives
Playwright WebKit and pins both halves of the documented residual: overflow-auto
still reserves nothing, overflow-y: scroll still reserves 15px. Both new sections
fail against the pre-fix CSS (verified by running the harness against the
previous dist) and pass against this one. A CSS-text test locks the nesting so no
rule can escape the gate, since no engine the harness can run reaches that path.
@petekp
petekp merged commit 595b598 into main Jul 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant