diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fe790ae..53e569d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,4 +31,6 @@ jobs:
run: npm test
- name: Browser verify chain
- run: npm run verify
+ # verify-scrollbar stays headed so Chromium paints the bars it samples.
+ # The Linux runner has no display, so provide one with Playwright's Xvfb.
+ run: xvfb-run -a npm run verify
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8fed392..ecf00cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,97 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+## [0.8.0] - 2026-07-31
+
+### Added
+
+- `fade-scrollbar-safe-y` / `-x` / `-xy`: opt-in utilities that keep a classic
+ (space-consuming) scrollbar from fading with the content. Each adds an opaque
+ mask strip over the scrollbar's column or row, unioned onto the fade via
+ `mask-composite: add`; `-y` and `-xy` also reserve a stable inline gutter. **The
+ suffix is the scroll axis, not the faded edge** — `-y` follows `overflow-y` and
+ shields the vertical bar at the inline end, which is the opposite convention
+ from this plugin's own `fade-y` (top + bottom edges). In practice the two agree:
+ a y-scroller wants `fade-y` and `fade-scrollbar-safe-y`. Because CSS offers no
+ way to measure the reserved gutter from the element the mask is on, the
+ utilities **pin** the bar with
+ `::-webkit-scrollbar` and sizes the strip from the same custom property, so bar
+ and strip agree by construction and stay aligned under page zoom. The trade is
+ that the bar becomes classic on every platform, including macOS. Also gives the
+ bar a transparent track and a `currentColor`-derived thumb so the gutter
+ doesn't read as a separate panel; override via `--tw-fade-scrollbar-thumb`
+ (color) and `fade-scrollbar-width-*` / `--tw-fade-scrollbar-width` (bar and
+ strip width, default `15px`). RTL-aware and inert until applied. Not supported
+ in Firefox, which ignores `::-webkit-scrollbar` and whose overlay/classic mode
+ can't be detected.
+- The opt-in is gated on `@supports (animation-timeline: scroll())` in its
+ entirety — the `::-webkit-scrollbar` pin included, not just the mask strip.
+ Without scroll gating the plugin's static fallback pins every selected fade on
+ regardless of overflow, and a faded container with no overflow has no reserved
+ gutter for the strip to land on: measured in WebKit, a plain
+ `overflow-auto fade-y fade-scrollbar-safe-y` whose content did not overflow
+ faded its inline end to fully transparent and then painted a hard 15px opaque
+ band of content beyond it, on markup that never mentions `fade-always`.
+ Standing down only the strip would be worse than useless, because the pin is
+ what makes the bar classic to begin with — pinned-but-unshielded manufactures
+ a space-consuming bar on macOS and then lets the fade dim it. So Safari
+ 17.x/18.x and release Firefox keep their native scrollbars instead. The branch
+ is unreachable in any engine the harness can run, so it is verified by
+ rewriting the feature query in the shipped CSS.
+- The three axes are independent rather than nested, because the strips have
+ different preconditions. The block gutter is only reserved when content actually
+ overflows horizontally — no `scrollbar-gutter` value reserves it unconditionally
+ — so a block strip applied blind would paint an opaque band over content on
+ every vertical-only scroller; hence `-x` and `-xy` rather than a block strip in
+ the base class. Conversely only `-y` and `-xy` set `scrollbar-gutter: stable`,
+ since `-x` says the container has no vertical bar and reserving an inline gutter
+ for it would put the strip on content. `-xy` is needed because pinning the bar
+ is a per-element opt-in rather than a per-axis one: a two-axis scroller gets a
+ classic horizontal bar whether or not the utility asks for one.
+- `fade-scrollbar-width-sm|md|lg` (`11px` / `15px` / `17px`), backed by the
+ `--fade-scrollbar-width-*` theme namespace. A pure setter: on its own it turns
+ nothing on, because only the `fade-scrollbar-safe-*` utilities read it. The
+ underlying property inherits, so its most useful home is an ancestor —
+ `
` states a house style once for every
+ opted-in scroller below. Absolute px rather than the spacing ramp (a scrollbar
+ is device chrome, not typographic rhythm), and `[length]` only — a bare integer
+ would read as px here and as spacing steps everywhere else in the plugin.
+ Widths from arbitrary utilities, theme overrides, and direct
+ `--tw-fade-scrollbar-width` declarations are clamped at `0px`, so a negative
+ length cannot invalidate the mask.
+
+### Fixed
+
+- The inline-end fade now reaches transparent at the content edge on containers
+ with a classic scrollbar. Mask percentages resolve against a box that includes
+ the reserved gutter while content stops at the scrollport edge, so an
+ inline-end ramp authored to hit zero at `100%` hit zero inside the gutter and
+ never finished over real content — measured at 40/255 still opaque on the last
+ content pixel with a 60px band and the 15px default bar, and 161/255 under a
+ 26px band, versus 0/255 and 1/255 once the layer is inset by the pinned width.
+ Tighter bands are hit harder, since the gutter eats a larger share of the ramp.
+ Inert at the `0px` default.
+- The scrollbar strips no longer make `@property` load-bearing for the whole
+ plugin. `mask-size` references `--tw-fade-scrollbar`, which is only declared
+ inside `@supports selector(::-webkit-scrollbar)`; everywhere else its value came
+ solely from the `@property` initial value. On an engine without `@property`
+ (Safari 15.4–16.3, Firefox < 128) that reference is guaranteed-invalid, which
+ invalidated the entire `mask-size` declaration at computed-value time — all six
+ layers fell back to `auto`, the two `0px` strips became full-size opaque layers,
+ and the fade disappeared completely. Every reference now carries an explicit
+ fallback. Verified by re-rendering the shipped CSS with the `@property` blocks
+ stripped out.
+- The scrollbar strip is anchored to the padding box rather than the border box,
+ so it tracks the gutter on a scroll container with a border. Anchored to the
+ border box it was displaced outward and straddled the border, shielding the
+ border while leaving that many pixels of scrollbar still faded.
+- Scrollbar widths are clamped before they reach either the pinned bar or the
+ mask strips. A negative arbitrary value such as
+ `fade-scrollbar-width-[-5px]`, or a negative direct custom-property value, no
+ longer invalidates the complete `mask-size` declaration and disables the fade.
+
+## [0.7.1] - 2026-06-29
+
### Fixed
- Nudged scroll-driven animation ranges away from exact start/end endpoints so
@@ -57,5 +148,7 @@ it also explains the [naming rationale](./MIGRATING.md#why-plain-directions).
`fade-size-*` / `fade-range-*` / `fade-clear-*` families). No public physical
horizontal utility (`fade-left` / `fade-right`) is provided.
-[Unreleased]: https://github.com/petekp/tw-fade/compare/v0.7.0...HEAD
+[Unreleased]: https://github.com/petekp/tw-fade/compare/v0.8.0...HEAD
+[0.8.0]: https://github.com/petekp/tw-fade/compare/v0.7.1...v0.8.0
+[0.7.1]: https://github.com/petekp/tw-fade/compare/v0.7.0...v0.7.1
[0.7.0]: https://github.com/petekp/tw-fade/releases/tag/v0.7.0
diff --git a/README.md b/README.md
index 24820b2..72fffac 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ npm install tw-fade
Use this for plain HTML, CDN usage, or bundlers that import CSS directly.
```html
-
+
```
```js
@@ -227,6 +227,82 @@ These utilities change only the active fade amount. They do not select edges on
...
```
+## Scrollbars
+
+The fade is a mask on the scroll container, and a mask can't distinguish content from the container's own scrollbar. Whether that matters depends on how the browser paints the scrollbar:
+
+- **Overlay scrollbars** — the default on macOS, iOS, and Android — paint in a separate layer on top of the element, so the mask never touches them. Nothing to do.
+- **Classic (space-consuming) scrollbars** — the default on Windows and Linux, on macOS with *Show scroll bars → Always*, and any bar you style with `::-webkit-scrollbar` — paint inside the element's box, so the fade dims the scrollbar's ends along with the content.
+
+If the container doesn't need a visible scrollbar, the shortest fix is to remove it — Tailwind's `scrollbar-none` (or `scrollbar-width: none`) takes the bar out of the box entirely and there is nothing left to fade. Reach for that first; it costs nothing and works everywhere.
+
+When you *do* want the bar visible, add the `fade-scrollbar-safe-*` class for the axis you scroll. It shields the scrollbar's column with an opaque strip in the mask, so the content still fades while the scrollbar stays fully opaque.
+
+| Utility | Effect |
+| --- | --- |
+| `fade-scrollbar-safe-y` | shields the **vertical** bar (from scrolling on y) |
+| `fade-scrollbar-safe-x` | shields the **horizontal** bar (from scrolling on x) |
+| `fade-scrollbar-safe-xy` | both |
+| `fade-scrollbar-width-sm\|md\|lg` | sets the bar width: `11px` / `15px` (default) / `17px` |
+
+**The suffix is the scroll axis, not the faded edge.** `-y` follows `overflow-y` — it's the vertical bar at the inline end. It deliberately does *not* follow this plugin's own `fade-y` (top + bottom edges). In practice the two agree, which is why it reads cleanly:
+
+```html
+
...
+```
+
+Match the class to the axis that actually scrolls. Putting `-y` on a horizontal-only scroller reserves a gutter for a vertical bar that never appears, and the strip then lands on your content — a bright notch at the fading edge. `-x` is the correct class there, and it reserves nothing.
+
+`fade-scrollbar-width-*` is a setter, not a switch — on its own it turns nothing on. It also inherits, so the usual place for it is an ancestor:
+
+```html
+
+
+
+```
+
+The named widths come from the `--fade-scrollbar-width-*` theme namespace and override like any other token:
+
+```css
+@theme {
+ --fade-scrollbar-width-md: 13px;
+}
+```
+
+On the Tailwind source path the utility also takes any length — `fade-scrollbar-width-[13px]`. A bare integer is deliberately rejected: everywhere else in this plugin an integer means spacing steps, and here it would have to mean pixels. Any width that reaches the plugin is clamped at `0px`, whether it comes from an arbitrary utility, a theme-token override, or a direct `--tw-fade-scrollbar-width` declaration. Negative lengths therefore behave as `0px` instead of invalidating the mask.
+
+### The trade it makes
+
+For the strip to line up with the scrollbar, the plugin has to know the bar's width — and CSS gives it no way to ask. The reserved gutter sits *inside* every box the mask can reference, so no `mask-clip` value excludes it; container query units can measure it but only descendants can read them, and the mask is on the container itself.
+
+So `fade-scrollbar-safe-*` doesn't measure the bar, it **declares** it. It pins the bar via `::-webkit-scrollbar` and sizes the strip from the same custom property, which makes them equal by construction rather than by luck — including under page zoom, where a native bar's width in CSS px drifts but two declared lengths scale together.
+
+The cost: pinning the bar makes it **classic on every platform**, including macOS, where you'd otherwise get an overlay bar and no bug at all. Apply it when your users see classic scrollbars, or when you want a consistent bar everywhere — not by default.
+
+It also fixes a quieter symptom of the same root cause. Mask percentages resolve against a box that includes the gutter, while content stops at the scrollport edge, so an inline-end fade authored to reach transparent at `100%` reaches it *inside the gutter* — over real content it never finishes. How badly depends on the ratio: with a 15px bar the last content pixel measured 40/255 opaque under a 60px band, and 161/255 under a 26px one — the tighter the band, the larger the share of it spent inside the gutter. Pinning the width lets the plugin inset that layer so the ramp lands on the content edge instead (0/255 and 1/255 respectively).
+
+### Styling
+
+The reserved gutter shouldn't read as a separate panel, so the utility also gives the bar a **transparent track** and a subtle thumb derived from the container's `currentColor` (adapts to light and dark). Knobs:
+
+- `--tw-fade-scrollbar-thumb` — the thumb color. Defaults to `color-mix(in srgb, currentColor 30%, transparent)`.
+- `--tw-fade-scrollbar-width` — what `fade-scrollbar-width-*` sets, for a value off the scale. Defaults to `15px`, matching a macOS classic bar, and clamps negative values to `0px`. Set this rather than `--tw-fade-scrollbar`: it drives the bar and the strip together, so they can't disagree. The pin *replaces* the native bar rather than decorating it, so this is your users' actual hit target — going much below native trades pointer accuracy for looks.
+
+```html
+
...
+```
+
+Do **not** set `scrollbar-width` or `scrollbar-color` on a `fade-scrollbar-safe-*` container — including via Tailwind's own `scrollbar-thin`, `scrollbar-auto`, `scrollbar-none`, `scrollbar-thumb-*`, and `scrollbar-track-*`. Either property makes Blink discard `::-webkit-scrollbar` styling entirely (`scrollbar-width` does the same in WebKit), which cancels the pin and takes the gutter to `0` — leaving a 15px opaque strip sitting on your content. Use `fade-scrollbar-width-*` for the size, and `::-webkit-scrollbar-track` / `::-webkit-scrollbar-thumb` for the colors.
+
+### Limits
+
+- RTL is handled automatically — the strip follows the vertical bar to the inline-start edge.
+- **Pinning the bar is per-element, not per-axis.** Opting in makes *both* bars classic, so a container that scrolls both ways gets a classic horizontal bar whether or not you asked for one — which is why `-xy` exists, and why the axis suffix is worth getting right. The block strip can't just be on by default: unlike the inline gutter, the block gutter is only reserved when content actually overflows horizontally, so a blind block strip would slab an opaque band over content on every vertical-only scroller.
+- **Firefox is not covered.** It ignores `::-webkit-scrollbar`, so its bar can't be pinned, and whether it's using overlay or classic bars can't be detected either. A guessed strip would be right on Windows/Linux and an opaque slab over content on macOS, with no way to tell which — and a missed strip damages content, while the bug it prevents only dims chrome. Firefox gets a thin styled bar and no strip only when scroll-driven animations are enabled. Release Firefox fails that feature gate, so this opt-in leaves its native scrollbar unchanged.
+- **In Safari, a container that isn't currently overflowing on the block axis has no gutter** (WebKit doesn't honor `scrollbar-gutter: stable`'s reserve-anyway behavior for custom bars), so the inline strip lands on content — an opaque band exactly where the fade just finished. It shows when such a container is faded anyway: with `fade-always*`, or with a live fade on the *other* axis (a two-axis scroller currently overflowing only horizontally). **Fix it with `overflow-y-scroll` instead of `overflow-y-auto`** — a permanent bar means a permanent gutter, in WebKit too (measured: 15px reserved, strip landing exactly on the track). Or apply the class only on a state that actually overflows.
+- **The whole opt-in requires scroll-driven animations, and stands down without them.** On Safari 17.x / 18.x and on release Firefox, the plugin falls back to pinning fades on regardless of overflow — and a faded container with no overflow is precisely the case above. Rather than shield a bar on those engines, the utility declines to pin one, so they keep their native scrollbars: overlay on macOS, which the mask never touches. Pinning without shielding would be worse than not opting in at all — it manufactures a space-consuming bar and then lets the fade dim it.
+- On overlay-scrollbar platforms the bar already paints above the mask and is never faded, so nothing is needed there — the utility trades that away deliberately in exchange for a bar it can shield exactly.
+
## Fading The Whole Page
Fade the element that scrolls. For a full-page fade, make `` the scroll container and keep the surface behind it on ``.
@@ -251,6 +327,7 @@ Two details matter:
| Needs Tailwind v4 | yes | no |
| Direction utilities | yes | yes |
| Named size/travel/clear utilities | yes | yes |
+| `fade-scrollbar-safe-*` and named `fade-scrollbar-width-*` | yes | yes |
| `fade-clear-*-var` | yes | yes |
| Arbitrary values like `fade-size-[6rem]` | yes | no |
| Integer clear values like `fade-clear-top-14` | yes | no |
@@ -269,7 +346,7 @@ The prebuilt file is generated from an explicit safelist. It does not include Ta
## How It Works
-Each faded element gets a four-layer `mask-image`, one layer per physical edge. Inactive layers fall back to an opaque identity mask. Active layers use a 13-stop eased gradient.
+Each faded element gets a six-layer `mask-image`: four edge gradients, one per physical edge, combined with `intersect`, plus two scrollbar-shield strips unioned in front of them with `add`. Inactive edge layers fall back to an opaque identity mask; active ones use a 13-stop eased gradient. The strips are `0px` by default and inert — a union can only raise opacity — until a [`fade-scrollbar-safe-*`](#scrollbars) class widens one.
The public API uses plain direction names, but the internal engine still keeps four physical edge amounts. Those internal properties are typed numbers, do not inherit, and are driven by scroll animations. Keeping the engine physical makes the mask predictable; the public `start` and `end` utilities route to the correct physical side for LTR or RTL in browsers that support the `:dir()` selector (see [Browser Support](#browser-support)).
@@ -352,7 +429,7 @@ Known no-overflow escape hatch:
```
-The supported public surface is the `fade-*` utilities, the public `--fade-*` tokens described above, and `--tw-fade-onset` (the edge-speed knob documented under [Travel Distance](#travel-distance)). Treat the rest of the `--tw-fade-*` namespace as internal implementation detail.
+The supported public surface is the `fade-*` utilities, the public `--fade-*` theme tokens described above (`--fade-size-*`, `--fade-travel-*`, `--fade-clear-*`, `--fade-scrollbar-width-*`), and three author knobs: `--tw-fade-onset` (the edge-speed knob under [Travel Distance](#travel-distance)) plus `--tw-fade-scrollbar-width` and `--tw-fade-scrollbar-thumb` (both under [Scrollbars](#scrollbars)). Treat the rest of the `--tw-fade-*` namespace — including `--tw-fade-scrollbar` and `--tw-fade-scrollbar-block` — as internal implementation detail.
## RTL
diff --git a/build/verify-dist.mjs b/build/verify-dist.mjs
index c974fa4..ea239f3 100644
--- a/build/verify-dist.mjs
+++ b/build/verify-dist.mjs
@@ -60,6 +60,7 @@ async function probe(sel, top) {
animationRangeEnd: cs.animationRangeEnd,
maskComposite: cs.maskComposite,
maskImage: cs.maskImage.slice(0, 40),
+ maskLayerCount: (cs.maskImage.match(/linear-gradient/g) || []).length,
}
},
[sel, top],
@@ -115,7 +116,7 @@ const checks = [
['dist: mixed fade-top fade-end composes (t≈1,r≈1)', approx(r.combo.t, 1) && approx(r.combo.r, 1)],
['dist: mixed fade-top fade-end keeps four travel animations', r.combo.animationNames.length === 4],
['dist: mask-composite intersect', /intersect/.test(r.top.maskComposite)],
- ['dist: mask-image has 4 layers', (r.top.maskImage.match(/gradient/g) || []).length >= 1],
+ ['dist: mask-image has 6 layers', r.top.maskLayerCount === 6],
]
console.log(JSON.stringify(r, null, 2))
diff --git a/build/verify-scrollbar.mjs b/build/verify-scrollbar.mjs
new file mode 100644
index 0000000..d1fbe55
--- /dev/null
+++ b/build/verify-scrollbar.mjs
@@ -0,0 +1,965 @@
+/**
+ * verify-scrollbar.mjs — pixel-level regression for `fade-scrollbar-safe-y`.
+ *
+ * The mask on a scroll container also masks that container's scrollbar: masking
+ * applies after the element and its descendants are drawn to one temporary
+ * canvas, and a "visible scrolling mechanism" is part of that canvas. Overlay
+ * scrollbars are composited separately and escape, which is why the bug is
+ * invisible on a Mac and obvious on Windows.
+ *
+ * `fade-scrollbar-safe-y` unions an opaque strip over the scrollbar's column. The
+ * strip's width cannot be derived — the reserved gutter is inside the mask's
+ * reference boxes by spec — so the utility PINS the bar via ::-webkit-scrollbar
+ * and sizes the strip from the same custom property. Bar and strip are then
+ * equal by construction.
+ *
+ * The failure this file exists to catch is the strip MISSING the gutter. A strip
+ * wider than the gutter is not a cosmetic slip: it restores content to full
+ * opacity with a hard vertical seam, inverting the plugin's whole purpose at the
+ * exact edge it exists to soften. CSS-text assertions cannot see that; only
+ * pixels can. Covered:
+ *
+ * 1. the pin holds — the used gutter equals the declared bar width exactly
+ * 2. the scrollbar column is fully opaque while content beside it still fades
+ * 3. base fades are untouched — a 0px strip contributes nothing, byte for byte
+ * 4. no opaque column appears anywhere the gutter is 0 (the notch regression)
+ * 5. the pin survives page zoom, where a NATIVE bar's CSS-px width drifts as
+ * 1/zoom but two author-declared lengths scale together
+ * 6. a unitless --tw-fade-scrollbar degrades to inert instead of invalidating
+ * mask-size and unioning an opaque layer over the entire element
+ * 7. the strip follows the gutter through a border, because it is anchored to
+ * the padding box rather than the border box
+ * 8. the axis suffix is the SCROLL axis: -y shields the vertical bar, -x the
+ * horizontal one, -xy both — and naming the axis is what keeps an x-only
+ * scroller from notching, since -x reserves no inline gutter
+ * 9. the fade survives with @property stripped out, so the strips never make
+ * a registered property load-bearing for the whole plugin
+ * 10. the entire opt-in -- pin included -- stands down on an engine without
+ * scroll gating, where the static fallback would otherwise pin fades on
+ * regardless of overflow and drop the strip onto content
+ * 11. WebKit's non-overflowing residual is still exactly as documented, and
+ * overflow-y: scroll still closes it
+ * 12. negative direct, theme-token, and arbitrary-utility widths clamp to 0px
+ * in both Chromium and WebKit, leaving mask-size valid and the fade intact
+ *
+ * Usage: node build/verify-scrollbar.mjs [path-to-css] (defaults to dist)
+ */
+import { chromium, webkit } from 'playwright'
+import { fileURLToPath } from 'node:url'
+import fs from 'node:fs'
+import path from 'node:path'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const cssPath = process.argv[2] || path.resolve(__dirname, '../dist/tw-fade.css')
+const css = fs.readFileSync(cssPath, 'utf8')
+
+const PIN = 15 // the utility's default declared bar width
+
+/*
+Headed on purpose. Headless Chromium RESERVES a scrollbar gutter but never
+PAINTS the bar, so every pixel sampled in that column is content showing through
+an unpainted track. A headless run of this file passes while asserting nothing
+about a scrollbar — which is exactly the false green this file exists to avoid.
+
+Worse on the block axis: headless reserves nothing at all for a horizontal
+custom bar (measured offsetHeight - clientHeight === 0 on a container whose
+vertical bar correctly takes 15px), so the -x assertions would not merely sample
+the wrong pixels, they would have no row to sample.
+*/
+const HEADED = { headless: false }
+
+/*
+The bar is given loud, known colours so an assertion can say "the SCROLLBAR is
+unfaded" instead of the much weaker "these pixels are opaque". Content is white
+and the page is blue, so content bleeding into the gutter is equally legible.
+`.bar` pins a classic bar WITHOUT the utility, giving a like-for-like control:
+the only difference between control and subject is the shield itself.
+*/
+const TRACK = [0, 255, 0]
+const HARNESS = `
+ * { margin: 0; box-sizing: border-box; }
+ html { font-size: 16px; }
+ body { background: #00f; }
+ .panel { width: 220px; height: 180px; overflow: auto; background: transparent; }
+ .plane { width: 700px; height: 700px; background: #fff; }
+ .bar::-webkit-scrollbar { width: ${PIN}px; height: ${PIN}px; }
+ .panel::-webkit-scrollbar-track { background: rgb(${TRACK.join(' ')}); }
+ .panel::-webkit-scrollbar-thumb { background: #f0f; }
+`
+
+const failures = []
+const notes = []
+const check = (ok, label, detail) => {
+ if (ok) console.log(` ok ${label}`)
+ else {
+ console.log(` FAIL ${label}\n ${detail}`)
+ failures.push(label)
+ }
+}
+
+const browser = await chromium.launch(HEADED)
+
+/**
+ * Render `body`, scroll every panel to its midpoint so leading and trailing
+ * fades are both active, then return per-panel geometry plus a luminance profile
+ * of the last `n` px of a horizontal line `yOff` into the top fade band.
+ *
+ * Sampling inside the fade band is what makes the strip legible: out in the
+ * un-faded middle of the panel, content and strip are both opaque and a misfire
+ * is invisible.
+ */
+async function sample({
+ body,
+ ids,
+ yOff = 6,
+ n = 24,
+ zoom = 1,
+ xOff = 6,
+ m = 24,
+ layout = '',
+ extraCss = '',
+ browserInstance = browser,
+}) {
+ // `layout` exists so a section that samples near a panel's BLOCK END can lay
+ // the panels out in a row. Stacked, the third panel falls below the 400px
+ // viewport and getImageData returns zeros — which read as "not content" and
+ // pass vacuously. Same false-green shape as running this file headless.
+ const html = `${body}`
+ const page = await browserInstance.newPage({ viewport: { width: 1000, height: 400 }, deviceScaleFactor: 1 })
+ await page.setContent(html, { waitUntil: 'load' })
+ if (zoom !== 1) await page.evaluate((z) => { document.documentElement.style.zoom = String(z) }, zoom)
+ await page.evaluate(async (list) => {
+ for (const id of list) {
+ const el = document.getElementById(id)
+ if (!el) continue
+ el.scrollTop = (el.scrollHeight - el.clientHeight) / 2
+ el.scrollLeft = (el.scrollWidth - el.clientWidth) / 2
+ }
+ await new Promise((r) => setTimeout(r, 120))
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
+ }, ids)
+
+ const geo = await page.evaluate((list) => {
+ const o = {}
+ for (const id of list) {
+ const el = document.getElementById(id)
+ if (!el) continue
+ const r = el.getBoundingClientRect()
+ // offsetWidth/clientWidth are integers, which under page zoom rounds a
+ // fractional gutter by up to a full CSS px. Measure the scrollport from a
+ // width:100% child instead, whose rect is fractional.
+ const probe = el.querySelector('[data-probe]')
+ const zoomFactor = parseFloat(document.documentElement.style.zoom || '1')
+ const exact = probe ? (r.width - probe.getBoundingClientRect().width) / zoomFactor : null
+ const style = getComputedStyle(el)
+ const scrollbarStyle = getComputedStyle(el, '::-webkit-scrollbar')
+ o[id] = {
+ x: r.x, y: r.y, w: r.width, h: r.height,
+ gutter: Math.round((el.offsetWidth - el.clientWidth) * 1000) / 1000,
+ exactGutter: exact === null ? null : Math.round(exact * 1000) / 1000,
+ maskSize: style.maskSize,
+ scrollbarGutter: style.scrollbarGutter,
+ scrollbarWidth: scrollbarStyle.width,
+ scrollbarHeight: scrollbarStyle.height,
+ scrollbarStrip: style.getPropertyValue('--tw-fade-scrollbar').trim(),
+ }
+ }
+ return o
+ }, ids)
+
+ const shot = await page.screenshot({ type: 'png' })
+ const prof = await page.evaluate(
+ async ([b64, g, list, yo, count, xo, mCount]) => {
+ const img = new Image()
+ img.src = 'data:image/png;base64,' + b64
+ await img.decode()
+ const cv = document.createElement('canvas')
+ cv.width = img.width
+ cv.height = img.height
+ const ctx = cv.getContext('2d')
+ ctx.drawImage(img, 0, 0)
+ const rgb = (x, y) => {
+ const d = ctx.getImageData(Math.round(x), Math.round(y), 1, 1).data
+ return [d[0], d[1], d[2]]
+ }
+ const o = {}
+ for (const id of list) {
+ if (!g[id]) continue
+ const row = []
+ for (let i = count - 1; i >= 0; i--) row.push(rgb(g[id].x + g[id].w - 1 - i, g[id].y + yo))
+ // ...and a vertical column running up from the block end, for the
+ // horizontal bar. Taken near the inline start so it never crosses the
+ // vertical bar's column or the scrollbar corner.
+ const col = []
+ for (let i = mCount - 1; i >= 0; i--) col.push(rgb(g[id].x + xo, g[id].y + g[id].h - 1 - i))
+ const center = rgb(g[id].x + g[id].w / 2, g[id].y + yo)
+ o[id] = { row, col, center }
+ }
+ return o
+ },
+ [shot.toString('base64'), geo, ids, yOff, n, xOff, m],
+ )
+ await page.close()
+ return {
+ geo,
+ prof: mapVals(prof, (v) => v.row),
+ col: mapVals(prof, (v) => v.col),
+ center: mapVals(prof, (v) => v.center),
+ }
+}
+
+const mapVals = (o, f) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, f(v)]))
+
+const panel = (id, cls, extra = '') =>
+ `
` +
+ `
`
+
+// Distance from the known track colour: 0 = the bar is painted at full strength,
+// large = the bar has been faded toward the page behind it.
+const trackDelta = (px) =>
+ Math.max(Math.abs(px[0] - TRACK[0]), Math.abs(px[1] - TRACK[1]), Math.abs(px[2] - TRACK[2]))
+const isContent = (px) => px[0] > 200 && px[1] > 200 && px[2] > 200
+const fmt = (row) => row.map((p) => p.join(',')).join(' ')
+
+/*
+Tailwind accepts -5px as an arbitrary [length], so the runtime must sanitize it
+rather than relying on the compiler or @property to reject it. The unit test
+proves Tailwind emits this exact setter; the verifier repeats it here because the
+framework-free dist intentionally excludes arbitrary utilities.
+*/
+const NEGATIVE_ARBITRARY_CSS =
+ '.fade-scrollbar-width-\\[-5px\\] { --tw-fade-scrollbar-width: -5px; }'
+
+async function verifyNegativeWidths(browserInstance, engine) {
+ const cases = [
+ ['negative-direct', 'direct --tw-fade-scrollbar-width'],
+ ['negative-theme', 'negative --fade-scrollbar-width-md theme token'],
+ ['negative-arbitrary', 'negative arbitrary width utility'],
+ ]
+ const common = 'fade-y fade-always-y fade-scrollbar-safe-y'
+ const { geo, center } = await sample({
+ body:
+ panel('zero', common, '--tw-fade-scrollbar-width:0px') +
+ panel('negative-direct', common, '--tw-fade-scrollbar-width:-5px') +
+ panel('negative-theme', `${common} fade-scrollbar-width-md`, '--fade-scrollbar-width-md:-5px') +
+ panel('negative-arbitrary', `${common} fade-scrollbar-width-[-5px]`),
+ ids: ['zero', ...cases.map(([id]) => id)],
+ layout: 'body { display: flex; }',
+ extraCss: NEGATIVE_ARBITRARY_CSS,
+ browserInstance,
+ })
+
+ // White content over a blue page: the center pixel's red channel is the
+ // rendered mask alpha, sampled well away from the scrollbar.
+ // Assert the baseline is genuinely faded before comparing the negative cases
+ // to it, or an all-opaque false green would pass every equality below.
+ const baseline = center.zero[0]
+ check(
+ baseline < 160,
+ `${engine}: the zero-width control still renders the fade`,
+ `control center pixel was ${baseline}/255 red — the control is opaque, so comparisons prove nothing`,
+ )
+
+ for (const [id, label] of cases) {
+ check(
+ geo[id].scrollbarStrip === '0px' && geo[id].gutter === 0,
+ `${engine}: ${label} clamps bar and strip to 0px`,
+ `strip computed to "${geo[id].scrollbarStrip}" and gutter to ${geo[id].gutter}px`,
+ )
+ check(
+ !geo[id].maskSize.includes('auto'),
+ `${engine}: ${label} leaves the six-layer mask-size valid`,
+ `mask-size computed to "${geo[id].maskSize}" — one negative component invalidated every layer`,
+ )
+ const rendered = center[id][0]
+ check(
+ Math.abs(rendered - baseline) <= 4,
+ `${engine}: ${label} leaves the fade intact`,
+ `center pixel was ${rendered}/255 red vs the ${baseline}/255 zero-width control`,
+ )
+ }
+}
+
+/* ------------------------------------------------------------------ *
+ * 1 + 2. The pin holds, and the bar itself survives the fade.
+ *
+ * `bare` has an identical pinned bar but no shield, so it reproduces the bug
+ * and proves the assertion below can fail.
+ * ------------------------------------------------------------------ */
+console.log('\nthe pin, and what it shields')
+{
+ const { geo, prof } = await sample({
+ body:
+ panel('bare', 'bar fade-y fade-always-y') +
+ panel('safe', 'fade-y fade-always-y fade-scrollbar-safe-y'),
+ ids: ['bare', 'safe'],
+ })
+
+ check(
+ geo.safe.gutter === PIN,
+ `the declared bar width is the used gutter (${PIN}px)`,
+ `gutter measured ${geo.safe.gutter}px — ::-webkit-scrollbar was cancelled, or the pin did not apply`,
+ )
+
+ // Sample the middle of the track column, deep inside the top fade band.
+ const at = (row) => row[row.length - Math.ceil(PIN / 2)]
+ const safeDelta = trackDelta(at(prof.safe))
+ const bareDelta = trackDelta(at(prof.bare))
+
+ check(
+ bareDelta > 40,
+ 'the control reproduces the bug (an unshielded bar fades)',
+ `unshielded bar was only ${bareDelta} from full strength — the control is not exercising the bug, ` +
+ `so the next assertion proves nothing`,
+ )
+ check(
+ safeDelta <= 8,
+ 'the shielded scrollbar keeps its colour through the fade band',
+ `shielded bar was ${safeDelta} away from the track colour (want <= 8): ${at(prof.safe).join(',')}`,
+ )
+ // Just inside the strip, content must still be dissolving toward the page. If
+ // the strip overhangs the gutter it restores content to pure white instead.
+ const inside = prof.safe[prof.safe.length - PIN - 1]
+ check(
+ !isContent(inside),
+ 'content immediately inside the strip is still faded',
+ `the pixel inside the strip is un-faded content (${inside.join(',')}) — the strip overhangs the gutter`,
+ )
+ notes.push(` bare (bug): ${fmt(prof.bare.slice(-14))}`)
+ notes.push(` safe (fix): ${fmt(prof.safe.slice(-14))}`)
+}
+
+/* ------------------------------------------------------------------ *
+ * 2b. The inline-end ramp completes ON THE CONTENT EDGE.
+ *
+ * Second symptom of the same root cause: mask percentages resolve against the
+ * border box, so a right-edge ramp authored to reach zero at 100% reaches zero
+ * inside the gutter and never finishes over visible content. `bar` is the
+ * control — same pinned bar, no utility — and shows the truncation.
+ * ------------------------------------------------------------------ */
+console.log('\nthe inline-end ramp')
+{
+ const { geo, prof } = await sample({
+ body:
+ panel('trunc', 'bar fade-x fade-always-x') +
+ panel('full', 'fade-x fade-always-x fade-scrollbar-safe-y'),
+ ids: ['trunc', 'full'],
+ yOff: 90, // mid-height, clear of the vertical fade bands
+ n: 24,
+ })
+ // Content is white on a blue page, so the red channel reads as the alpha the
+ // fade left behind. The last content pixel sits `gutter` columns in from the
+ // border-box edge.
+ const lastContent = (id) => prof[id][prof[id].length - 1 - geo[id].gutter][0]
+ check(
+ lastContent('trunc') > 25,
+ 'the control reproduces the truncated ramp',
+ `control faded to ${lastContent('trunc')}/255 at the content edge — nothing left to fix`,
+ )
+ check(
+ lastContent('full') <= 12,
+ 'the ramp reaches transparent at the content edge under the utility',
+ `still ${lastContent('full')}/255 opaque at the last content pixel (want <= 12) — ` +
+ `the inline-end layer is not inset by the gutter`,
+ )
+ notes.push(` ramp at last content px: without utility ${lastContent('trunc')}/255, with utility ${lastContent('full')}/255`)
+}
+
+/* ------------------------------------------------------------------ *
+ * 3. Base fades are untouched by the two always-present strip layers.
+ *
+ * Both strips sit on every masked element, so their inert default is what keeps
+ * the plugin's normal rendering byte-identical. The block strip is listed first
+ * (100% x 0px), then the inline strip (0px x 100%); a zero extent in either
+ * axis covers nothing, so `add` contributes nothing.
+ * ------------------------------------------------------------------ */
+console.log('\nthe inert strips')
+{
+ const { geo, prof } = await sample({
+ body: panel('a', 'fade') + panel('b', 'fade-y') + panel('c', 'fade-x') + panel('d', 'fade-top'),
+ ids: ['a', 'b', 'c', 'd'],
+ })
+ for (const id of ['a', 'b', 'c', 'd']) {
+ check(
+ geo[id].maskSize.startsWith('100% 0px, 0px 100%'),
+ `${id}: both strips are 0px without the utility`,
+ `mask-size was ${geo[id].maskSize}`,
+ )
+ }
+ for (const id of ['a', 'b', 'c']) {
+ const edge = prof[id][prof[id].length - 1]
+ check(
+ !isContent(edge),
+ `${id}: no un-faded column at the inline end without the utility`,
+ `trailing pixel is un-faded content (${edge.join(',')}) — the inert strip is painting`,
+ )
+ }
+}
+
+/* ------------------------------------------------------------------ *
+ * 4. The notch regression: no strip may appear where no gutter exists.
+ *
+ * `scrollbar-gutter: stable` reserves only where a scrollbar can appear. An
+ * x-only scroller (overflow-y: hidden) is the adversarial case — if the gutter
+ * is 0 there, a strip would sit on content.
+ * ------------------------------------------------------------------ */
+console.log('\nthe strip never restores content')
+{
+ const { geo, prof } = await sample({
+ body:
+ panel('xonly', 'fade-x fade-always-x fade-scrollbar-safe-y', 'overflow-y:hidden;overflow-x:auto') +
+ panel('bothaxes', 'fade fade-always fade-scrollbar-safe-y', 'overflow:auto') +
+ panel('yonly', 'fade-y fade-always-y fade-scrollbar-safe-y', 'overflow-y:auto;overflow-x:hidden') +
+ panel('scrollprop', 'fade-y fade-always-y fade-scrollbar-safe-y', 'overflow:scroll'),
+ ids: ['xonly', 'bothaxes', 'yonly', 'scrollprop'],
+ })
+ const restored = (id) => prof[id].slice(-PIN).filter(isContent).length
+
+ for (const id of ['bothaxes', 'yonly', 'scrollprop']) {
+ // The invariant wherever a vertical bar is actually painted: the shield may
+ // reveal the scrollbar, but it must NEVER hand back un-faded content. That
+ // is the notch, and it is the one outcome worse than the bug it fixes.
+ check(
+ restored(id) === 0,
+ `${id} (gutter ${geo[id].gutter}px): no un-faded content in the strip`,
+ `${restored(id)} of ${PIN} columns are un-faded content — this is the notch: ${fmt(prof[id].slice(-PIN))}`,
+ )
+ }
+
+ /*
+ Characterisation of the WRONG CLASS, not a defect. With `overflow-y: hidden`
+ Chromium still RESERVES the inline-end gutter for `scrollbar-gutter: stable`
+ but never paints a bar into it — and content is clipped to the padding box,
+ which per CSS Overflow 3 counts that reserved space as padding. So content
+ paints beneath an empty gutter and the inline shield restores it. This is what
+ -y does on a container that has no vertical bar; the fix is to name the axis
+ you actually scroll. Asserted so it stays a known quantity — if an engine ever
+ stops reserving here, this flips and the docs should follow.
+ */
+ check(
+ restored('xonly') > 0,
+ 'the -y class still notches an x-only scroller (wrong class, documented)',
+ 'x-only no longer notches under -y — an engine changed behaviour; update README',
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 4b. Naming the axis is what removes the notch.
+ *
+ * This is the whole reason the API is per-axis rather than one class plus an
+ * add-on. -x asserts "my bar is the horizontal one": no inline strip, and no
+ * scrollbar-gutter either, since that property only reserves on the inline axis
+ * and would be reserving for a vertical bar the author just said is absent. The
+ * same container that notches under -y must come out clean under -x.
+ * ------------------------------------------------------------------ */
+console.log('\nnaming the axis removes the notch')
+{
+ const { geo, prof } = await sample({
+ body:
+ panel('nx', 'fade-x fade-always-x fade-scrollbar-safe-x', 'overflow-y:hidden;overflow-x:auto') +
+ panel('ny', 'fade-x fade-always-x fade-scrollbar-safe-y', 'overflow-y:hidden;overflow-x:auto'),
+ ids: ['nx', 'ny'],
+ })
+ const restored = (id) => prof[id].slice(-PIN).filter(isContent).length
+
+ check(
+ restored('nx') === 0,
+ 'an x-only scroller under -x restores no content',
+ `${restored('nx')} of ${PIN} columns came back as un-faded content: ${fmt(prof.nx.slice(-PIN))}`,
+ )
+ check(
+ geo.nx.gutter === 0,
+ '-x reserves no inline gutter (that axis has no bar to reserve for)',
+ `the inline gutter measured ${geo.nx.gutter}px — is -x setting scrollbar-gutter?`,
+ )
+ // The comparison is the point: same markup, same container, different class.
+ check(
+ restored('ny') > 0 && restored('nx') === 0,
+ 'the axis suffix is what makes the difference, not the container',
+ `-y restored ${restored('ny')} columns and -x restored ${restored('nx')} — expected the first to notch ` +
+ `and the second not to; if both are clean the per-axis split has stopped earning its keep`,
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 5. Zoom. A native bar holds constant physical size, so its CSS-px width goes
+ * as 1/zoom; a declared one does not. The strip is a declared length, so only
+ * a declared bar stays aligned with it.
+ * ------------------------------------------------------------------ */
+console.log('\nzoom invariance')
+for (const zoom of [0.75, 1.25, 1.5, 2]) {
+ // The profile is in DEVICE px while PIN is CSS px, so the sampling window has
+ // to be sized for the zoomed bar. A fixed window silently truncates the run
+ // below and reads as "a faded sliver remains" — a phantom failure that only
+ // appears once PIN * zoom exceeds it.
+ const band = Math.ceil(PIN * zoom)
+ const { geo, prof } = await sample({
+ body: panel('z', 'fade-y fade-always-y fade-scrollbar-safe-y'),
+ ids: ['z'],
+ zoom,
+ n: band + 10,
+ })
+ // The bar is snapped to whole device pixels while the strip is an exact CSS
+ // length, so they can disagree by a sub-pixel at fractional zooms. Tolerate
+ // less than one CSS px of drift; anything more is a visible seam.
+ const drift = Math.abs(geo.z.exactGutter - PIN)
+ check(
+ drift < 1,
+ `zoom ${zoom}x: gutter holds ${PIN}px (measured ${geo.z.exactGutter}px)`,
+ `drifted ${drift.toFixed(3)}px — strip and bar no longer agree`,
+ )
+ // What actually matters: the bar keeps its colour, and no content is restored.
+ // Count the longest run of true track colour rather than one anchored to the
+ // final pixel — at fractional zoom the panel edge lands off-grid, so the
+ // outermost sample can fall on the page background.
+ const trackRun = (() => {
+ let best = 0
+ let n = 0
+ for (const px of prof.z) {
+ n = trackDelta(px) <= 8 ? n + 1 : 0
+ if (n > best) best = n
+ }
+ return best
+ })()
+ check(
+ trackRun >= Math.floor(PIN * zoom) - 2,
+ `zoom ${zoom}x: the bar stays unfaded across its full width (${trackRun} device px)`,
+ `only ${trackRun} device px held the track colour, expected about ${Math.round(PIN * zoom)} — ` +
+ `a faded sliver remains over the bar`,
+ )
+ // A nominal 15px strip can land one device pixel wider than the rasterized
+ // bar (for example, 18.75px rounds to an 18px gutter at 1.25x). Permit that
+ // single boundary pixel; two would be a real opaque slab over content.
+ const restored = prof.z.slice(-band).filter(isContent).length
+ check(
+ restored <= 1,
+ `zoom ${zoom}x: strip overhang stays within one rounded boundary pixel`,
+ `${restored} content pixels were restored at this zoom: ${fmt(prof.z.slice(-band))}`,
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 5b. An author-chosen width must move the BAR and the STRIP together.
+ *
+ * This is the one failure the pin exists to make impossible, and it is easy to
+ * reintroduce: the strip is read from the element, where any declaration is
+ * visible, but the bar is pinned from ::-webkit-scrollbar — a pseudo-element,
+ * which only sees a custom property by INHERITING it. Registered with
+ * inherits:false the pin silently used the initial value while the strip used
+ * the author's, so declaring 24px gave a 15px bar under a 24px strip: a 9px
+ * opaque slab over content. Asserting the gutter alone would not catch it
+ * either — the gutter follows the bar, so the slab is only visible in pixels.
+ * ------------------------------------------------------------------ */
+console.log('\nan author-chosen width moves bar and strip together')
+for (const width of [7, 18, 24]) {
+ const { geo, prof } = await sample({
+ body: panel('w', 'fade-y fade-always-y fade-scrollbar-safe-y', `--tw-fade-scrollbar-width:${width}px`),
+ ids: ['w'],
+ n: 34,
+ })
+ check(
+ geo.w.gutter === width,
+ `--tw-fade-scrollbar-width: ${width}px pins the bar to ${width}px`,
+ `the gutter measured ${geo.w.gutter}px — the declared width is not reaching ::-webkit-scrollbar ` +
+ `(is --tw-fade-scrollbar-width registered with inherits:false?)`,
+ )
+ // The decisive check: no fully-opaque content anywhere in the shielded band.
+ // A strip wider than the bar shows up here and nowhere else.
+ check(
+ prof.w.slice(-width).filter(isContent).length === 0,
+ `${width}px: the strip covers the bar exactly, with no slab over content`,
+ `the strip overhangs the bar by ${prof.w.slice(-width).filter(isContent).length}px: ${fmt(prof.w.slice(-width))}`,
+ )
+ const barRun = prof.w.slice(-width).filter((px) => trackDelta(px) <= 8).length
+ check(
+ barRun >= width - 1,
+ `${width}px: the whole bar stays unfaded`,
+ `only ${barRun} of ${width}px held the track colour — the strip is narrower than the bar`,
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 5c. The public utility, on the element and on an ancestor.
+ *
+ * 5b declares the custom property directly; this asserts the shipped API reaches
+ * the same place. The ancestor case is the reason the property inherits at all —
+ * one declaration high up should set a house style for every opted-in scroller
+ * below it. And the setter must stay INERT on its own: it is applied to page
+ * wrappers, and a setter that also enabled would pin their bars or paint a
+ * strip. Native classic scrollbars may still consume space on their own.
+ * ------------------------------------------------------------------ */
+console.log('\nthe fade-scrollbar-width-* utility')
+{
+ const { geo } = await sample({
+ body:
+ panel('onself', 'fade-y fade-always-y fade-scrollbar-safe-y fade-scrollbar-width-lg') +
+ `
` +
+ panel('setteronly', 'fade-y fade-always-y fade-scrollbar-width-lg'),
+ ids: ['onself', 'inherited', 'setteronly'],
+ })
+ check(
+ geo.onself.gutter === 17,
+ 'fade-scrollbar-width-lg pins the bar to its token value (17px)',
+ `the gutter measured ${geo.onself.gutter}px — the token or the utility is not resolving`,
+ )
+ check(
+ geo.inherited.gutter === 11,
+ 'the width set on an ancestor reaches the scroller below it (11px)',
+ `the gutter measured ${geo.inherited.gutter}px, not the ancestor's 11px — is the property still inherits:true?`,
+ )
+ check(
+ geo.setteronly.scrollbarWidth === 'auto' &&
+ geo.setteronly.scrollbarHeight === 'auto' &&
+ geo.setteronly.scrollbarGutter === 'auto' &&
+ geo.setteronly.scrollbarStrip === '0px' &&
+ geo.setteronly.maskSize.startsWith('100% 0px, 0px 100%'),
+ 'the setter alone turns nothing on — no author pin, stable gutter, or strip',
+ `native gutter ${geo.setteronly.gutter}px, ` +
+ `::-webkit-scrollbar ${geo.setteronly.scrollbarWidth} × ${geo.setteronly.scrollbarHeight}, ` +
+ `scrollbar-gutter ${geo.setteronly.scrollbarGutter}, strip ${geo.setteronly.scrollbarStrip}, ` +
+ `mask-size ${geo.setteronly.maskSize}`,
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 6. A bad value must degrade to inert, not invalidate mask-size.
+ *
+ * mask-size is one declaration: a single component that fails to parse makes
+ * ALL layers fall back to `auto`, which on a gradient is the full positioning
+ * area — an opaque layer over the whole element, with the fade silently gone.
+ * The typed @property registration is what prevents this.
+ * ------------------------------------------------------------------ */
+console.log('\nbad values degrade to inert')
+{
+ const { geo, prof } = await sample({
+ body:
+ panel('ok', 'fade-y fade-always-y') +
+ panel('unitless', 'fade-y fade-always-y', '--tw-fade-scrollbar:11') +
+ panel('garbage', 'fade-y fade-always-y', '--tw-fade-scrollbar:banana'),
+ ids: ['ok', 'unitless', 'garbage'],
+ })
+ for (const id of ['unitless', 'garbage']) {
+ check(
+ !geo[id].maskSize.includes('auto'),
+ `--tw-fade-scrollbar: ${id === 'unitless' ? '11' : 'banana'} does not invalidate mask-size`,
+ `mask-size computed to "${geo[id].maskSize}" — the fade is disabled entirely`,
+ )
+ const lum = (row) =>
+ Math.round(row.reduce((a, p) => a + 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2], 0) / row.length)
+ const mean = lum(prof[id])
+ const okMean = lum(prof.ok)
+ check(
+ Math.abs(mean - okMean) < 25,
+ `--tw-fade-scrollbar: ${id === 'unitless' ? '11' : 'banana'} renders as the unshielded fade`,
+ `mean luminance ${mean} vs baseline ${okMean}`,
+ )
+ }
+}
+
+/* ------------------------------------------------------------------ *
+ * 7. Borders. The strip is anchored to the PADDING box, not the border box.
+ *
+ * The gutter lies between the padding and border edges, so a border-box-anchored
+ * strip is displaced outward by the border width and straddles it — shielding
+ * the border (which never needed it) while leaving that many pixels of actual
+ * scrollbar still faded. Padding-box is exactly right because CSS Overflow 3
+ * §5.1 folds the reserved gutter into the padding area for painting, making the
+ * padding box's inline-end edge the gutter's outer edge.
+ * ------------------------------------------------------------------ */
+console.log('\nthe strip tracks the gutter through a border')
+{
+ const BORDER = 6
+ const { geo, prof } = await sample({
+ body: panel('bordered', 'fade-y fade-always-y fade-scrollbar-safe-y', `border-right:${BORDER}px solid #ff0`),
+ ids: ['bordered'],
+ })
+
+ check(
+ geo.bordered.gutter === PIN + BORDER,
+ `border + bar occupy ${PIN + BORDER}px`,
+ `measured ${geo.bordered.gutter}px — the harness is not exercising a bordered scroller`,
+ )
+
+ // Walk inward past the border; every pixel of track must be at full strength.
+ const row = prof.bordered
+ const track = row.slice(row.length - BORDER - PIN, row.length - BORDER)
+ const worst = Math.max(...track.map(trackDelta))
+ check(
+ worst <= 8,
+ `all ${PIN}px of track stay unfaded behind a ${BORDER}px border`,
+ `worst track pixel was ${worst} from full strength — the strip is anchored to the border box, ` +
+ `so it covers the border and misses the innermost ${BORDER}px of the bar. Track sampled: ${fmt(track)}`,
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 8. The block axis is opt-in, and works when opted into.
+ *
+ * The pin makes the horizontal bar classic too — the custom-scrollbar opt-in is
+ * per-element, not per-axis — so a container that scrolls both ways gets a
+ * manufactured horizontal bar that the block-end fade would otherwise erase.
+ * fade-scrollbar-safe-xy shields it. It cannot be the default: the block
+ * gutter is only reserved when content actually overflows horizontally, so a
+ * blind block strip would slab over content on every vertical-only scroller.
+ * ------------------------------------------------------------------ */
+console.log('\nthe block-axis strip')
+{
+ const { geo, col } = await sample({
+ body:
+ panel('hbare', 'fade fade-always fade-scrollbar-safe-y') +
+ panel('hboth', 'fade fade-always fade-scrollbar-safe-xy') +
+ panel('honly', 'fade fade-always fade-scrollbar-safe-x') +
+ panel('vonly', 'fade fade-always fade-scrollbar-safe-xy', 'overflow-x:hidden'),
+ ids: ['hbare', 'hboth', 'honly', 'vonly'],
+ layout: 'body { display: flex; }',
+ })
+
+ const at = (c) => c[c.length - Math.ceil(PIN / 2)]
+ check(
+ trackDelta(at(col.hbare)) > 40,
+ '-y alone leaves the manufactured horizontal bar faded',
+ `it measured ${trackDelta(at(col.hbare))} from full strength — if the block-end bar is already ` +
+ `protected, the -x axis has nothing to do and should be removed`,
+ )
+ check(
+ trackDelta(at(col.hboth)) <= 8,
+ '-xy shields the horizontal bar',
+ `block-end bar was ${trackDelta(at(col.hboth))} from full strength. Column sampled: ${fmt(col.hboth)}`,
+ )
+ // -x on a two-axis scroller: the horizontal bar is shielded and the vertical
+ // one is deliberately left alone. Proves the axes are genuinely independent
+ // rather than -x being a synonym for -xy.
+ check(
+ trackDelta(at(col.honly)) <= 8,
+ '-x shields the horizontal bar without opting into the inline axis',
+ `block-end bar was ${trackDelta(at(col.honly))} from full strength: ${fmt(col.honly)}`,
+ )
+ // Measured via the PROPERTY, not the gutter: on a scroller that really does
+ // overflow vertically the bar is present and occupies 15px whatever
+ // scrollbar-gutter says, so a gutter measurement here proves nothing.
+ check(
+ geo.honly.scrollbarGutter === 'auto' && geo.hboth.scrollbarGutter === 'stable',
+ '-x does not stabilize an inline gutter; -xy does',
+ `-x computed scrollbar-gutter:${geo.honly.scrollbarGutter}, -xy computed ${geo.hboth.scrollbarGutter}`,
+ )
+
+ // And the cost of opting in wrongly, stated as a test so it stays honest: with
+ // no horizontal overflow there is no block gutter, so the strip lands on
+ // content. This is the reason it is not the default.
+ check(
+ geo.vonly.maskSize.startsWith(`100% ${PIN}px`) && col.vonly.some(isContent),
+ 'opting in without horizontal overflow slabs content (documented cost)',
+ `expected the block strip to sit on content when there is no bar to shield; ` +
+ `mask-size was ${geo.vonly.maskSize}`,
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 9. The strips must not make @property load-bearing for the WHOLE plugin.
+ *
+ * mask-size references --tw-fade-scrollbar, but that property is only ever
+ * DECLARED inside @supports selector(::-webkit-scrollbar). On every other engine
+ * its value comes solely from the @property initial-value. Without @property
+ * support the reference is a guaranteed-invalid value, which makes the entire
+ * mask-size declaration invalid at computed-value time -> all six layers fall
+ * back to `auto` -> the two 0px strips become full-size opaque layers -> `add`
+ * paints over everything and the fade silently VANISHES.
+ *
+ * That is a total, silent loss of the plugin's only feature on Safari 15.4-16.3
+ * and Firefox < 128, which 0.7.1 rendered fine. Every var() in a load-bearing
+ * declaration therefore carries an explicit fallback, and this proves it: strip
+ * the @property blocks out of the shipped CSS and the fade must still render.
+ * Holding the engine constant isolates the mechanism instead of hunting old
+ * browser builds.
+ * ------------------------------------------------------------------ */
+console.log('\nthe fade survives without @property')
+{
+ const stripped = css.replace(/@property\s+[^{]+\{[^}]*\}\s*/g, '')
+ const page = await browser.newPage({ viewport: { width: 300, height: 260 }, deviceScaleFactor: 1 })
+ await page.setContent(
+ `` +
+ `${panel('np', 'fade-y fade-always-y')}`,
+ { waitUntil: 'load' },
+ )
+ await page.evaluate(() => { document.getElementById('np').scrollTop = 300 })
+ await page.waitForTimeout(120)
+ const maskSize = await page.evaluate(() => getComputedStyle(document.getElementById('np')).maskSize)
+ const shot = await page.screenshot({ type: 'png' })
+ const band = await page.evaluate(async (b64) => {
+ const img = new Image()
+ img.src = 'data:image/png;base64,' + b64
+ await img.decode()
+ const cv = document.createElement('canvas')
+ cv.width = img.width
+ cv.height = img.height
+ cv.getContext('2d').drawImage(img, 0, 0)
+ const ctx = cv.getContext('2d')
+ const out = []
+ for (let y = 0; y < 16; y += 2) out.push(ctx.getImageData(100, y, 1, 1).data[0])
+ return out
+ }, shot.toString('base64'))
+ await page.close()
+
+ check(
+ !maskSize.includes('auto'),
+ 'mask-size survives with @property removed',
+ `mask-size computed to "${maskSize}" — a var() in the mask block is missing its fallback, ` +
+ `so the whole declaration went invalid at computed-value time`,
+ )
+ check(
+ band[0] < 120,
+ 'the fade still renders with @property removed',
+ `the top of the fade band measured ${band[0]}/255 (opaque) — the fade is gone: ${band.join(' ')}`,
+ )
+}
+
+/* ------------------------------------------------------------------ *
+ * 10. The whole opt-in stands down when scroll gating is unavailable.
+ *
+ * Without animation-timeline: scroll() the plugin takes its static fallback,
+ * which pins every selected fade ON regardless of overflow. A faded container
+ * that is not overflowing has no reserved gutter, so the strip lands on content:
+ * measured in WebKit before this gate existed, a plain `overflow-auto fade-y
+ * fade-scrollbar-safe-y` whose content did not overflow faded its inline end to
+ * fully transparent and then painted a hard 15px opaque band of content beyond
+ * it -- on markup that never mentions fade-always.
+ *
+ * Dropping only the strip is not enough. The pin is what makes the bar classic,
+ * so pinned-but-unshielded is worse than never opting in: it manufactures a
+ * space-consuming bar on macOS and then lets the fade dim it. The entire
+ * utility therefore stands down together.
+ *
+ * No engine reachable from here lacks scroll-driven animations, so the branch is
+ * unreachable at runtime and would otherwise ship untested. Rewriting the
+ * feature query in the shipped CSS makes every @supports gate on it flip at
+ * once -- one substitution puts a modern engine into the old engine's branch.
+ * ------------------------------------------------------------------ */
+console.log('\nthe opt-in stands down without scroll gating')
+{
+ // Only ever appears inside @supports conditions; the real declaration uses
+ // var(--tw-fade-timeline-*), so this touches nothing but the feature queries.
+ const gates = (css.match(/animation-timeline: scroll\(\)/g) || []).length
+ const noSda = css.replace(/animation-timeline: scroll\(\)/g, 'animation-timeline: tw-fade-no-such-timeline()')
+ const page = await browser.newPage({ viewport: { width: 300, height: 260 }, deviceScaleFactor: 1 })
+ await page.setContent(
+ `` +
+ `
` +
+ `
`,
+ { waitUntil: 'load' },
+ )
+ await page.waitForTimeout(120)
+ const got = await page.evaluate(() => {
+ const el = document.getElementById('sd')
+ const cs = getComputedStyle(el)
+ return {
+ gutter: el.offsetWidth - el.clientWidth,
+ maskSize: cs.maskSize,
+ scrollbarGutter: cs.scrollbarGutter,
+ // the fallback is expected to still pin the fade on -- that is the
+ // premise this gate exists to defuse, so assert it rather than assume it
+ amounts: ['t', 'b', 'l', 'r'].map((k) => cs.getPropertyValue(`--tw-fade-${k}`).trim()).join('/'),
+ }
+ })
+ await page.close()
+
+ check(gates >= 2, 'the feature query is present to flip', `found ${gates} occurrences — the substitution is a no-op`)
+ check(
+ got.amounts === '1/1/1/1',
+ 'the static fallback still pins fades on regardless of overflow',
+ `amounts were ${got.amounts} — the premise of this section no longer holds, re-derive it`,
+ )
+ check(
+ got.scrollbarGutter === 'auto',
+ 'no gutter is reserved without scroll gating',
+ `scrollbar-gutter computed to "${got.scrollbarGutter}" — the opt-in did not stand down`,
+ )
+ check(
+ got.gutter === 0,
+ 'the bar is not pinned classic without scroll gating',
+ `the container reserved ${got.gutter}px — a pinned but unshielded bar is worse than no opt-in`,
+ )
+ check(
+ !got.maskSize.includes(`${PIN}px`),
+ 'no strip is declared without scroll gating',
+ `mask-size computed to "${got.maskSize}" — a strip is live on an engine that cannot reserve a gutter for it`,
+ )
+}
+
+console.log('\nnegative public widths clamp safely in Chromium')
+await verifyNegativeWidths(browser, 'Chromium')
+
+await browser.close()
+
+/* ------------------------------------------------------------------ *
+ * 11. WebKit: the residual, and the escape hatch that closes it.
+ *
+ * Blink honours scrollbar-gutter: stable for a custom bar and reserves the
+ * gutter even when the content does not overflow. WebKit does not, so a
+ * container that is faded but not currently overflowing has a 15px strip and no
+ * gutter for it to land on. That residual cannot be fixed in CSS -- the gutter's
+ * existence is not queryable -- so it is documented, and this section pins BOTH
+ * halves of that documentation so neither can rot:
+ *
+ * - overflow-y: scroll forces the gutter in WebKit too, which is the mitigation
+ * the README hands the reader
+ * - overflow-y: auto on a non-overflowing container does NOT, which is the
+ * limitation the README admits to
+ *
+ * Playwright's WebKit is a WebKit build, not Safari -- the same category of
+ * error as reading Firefox behaviour off Nightly. It is the right tool for
+ * pinning engine mechanics; it does not speak for a shipping Safari version.
+ * ------------------------------------------------------------------ */
+console.log('\nWebKit: the non-overflowing residual')
+{
+ const wk = await webkit.launch()
+ const page = await wk.newPage({ viewport: { width: 700, height: 300 }, deviceScaleFactor: 1 })
+ const shortPanel = (id, extra) =>
+ `
` +
+ `
`
+ await page.setContent(
+ `` +
+ `${shortPanel('wkauto', 'overflow:auto')}${shortPanel('wkscroll', 'overflow-y:scroll')}`,
+ { waitUntil: 'load' },
+ )
+ await page.waitForTimeout(150)
+ const got = await page.evaluate(() => ({
+ sda: CSS.supports('animation-timeline', 'scroll()'),
+ auto: (() => { const e = document.getElementById('wkauto'); return e.offsetWidth - e.clientWidth })(),
+ scroll: (() => { const e = document.getElementById('wkscroll'); return e.offsetWidth - e.clientWidth })(),
+ }))
+ await page.close()
+
+ check(
+ got.sda,
+ 'this WebKit build gates on scroll, so the utility is live in it',
+ 'WebKit reported no scroll-driven animations — section 10 covers that path, not this one',
+ )
+ check(
+ got.auto === 0,
+ 'WebKit still reserves no gutter for a non-overflowing overflow-auto container',
+ `it reserved ${got.auto}px — WebKit now honours scrollbar-gutter: stable for custom bars, so the ` +
+ `README's Safari limitation is stale and should be relaxed`,
+ )
+ check(
+ got.scroll === PIN,
+ 'overflow-y: scroll closes the residual in WebKit',
+ `overflow-y: scroll reserved ${got.scroll}px, expected ${PIN} — the mitigation the README ` +
+ `recommends to Safari users no longer works`,
+ )
+
+ console.log('\nnegative public widths clamp safely in WebKit')
+ await verifyNegativeWidths(wk, 'WebKit')
+ await wk.close()
+}
+
+if (notes.length) {
+ console.log(`\nr,g,b across the last 14px, deep in the top fade band (track is ${TRACK.join(',')}):`)
+ notes.forEach((n) => console.log(n))
+}
+
+if (failures.length) {
+ console.error(`\nverify-scrollbar: ${failures.length} failure(s)`)
+ failures.forEach((f) => console.error(` - ${f}`))
+ process.exit(1)
+}
+console.log('\nverify-scrollbar: all checks passed')
diff --git a/demo/scrollbar-test.html b/demo/scrollbar-test.html
new file mode 100644
index 0000000..34837a2
--- /dev/null
+++ b/demo/scrollbar-test.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+ fade-scrollbar-safe-* · test
+
+
+
+
+
fade-scrollbar-safe-*
+
+ The suffix is the scroll axis, not the faded edge: -y shields the
+ vertical bar you get from scrolling on y. Scroll each container to the middle and watch the
+ thumb's ends. The utility pins the bar classic on every platform, so the effect shows on macOS
+ overlay-scrollbar defaults too — which is why the unprotected control has no visible bar there.
+
+
+
+
+
+
+
+
+
+
fade-y — scrollbar fades (before)
+
+
Thumb ends dim in and out with the content.
+
+
+
fade-y fade-scrollbar-safe-y — protected
+
+
Content still fades; the bar stays crisp.
+
+
+
fade-x fade-scrollbar-safe-x — horizontal
+
+
+ -x shields the bottom bar and reserves no inline gutter — the wrong class
+ here (-y) would notch the fading edge.
+
+
+
+
fade fade-scrollbar-safe-xy — both axes
+
+
Pinning is per element, not per axis: a two-axis scroller needs both strips.
+
+
+
+
+
+
diff --git a/demo/styles.css b/demo/styles.css
index 6ef5250..57fdae5 100644
--- a/demo/styles.css
+++ b/demo/styles.css
@@ -713,6 +713,21 @@
syntax: "*";
inherits: false;
}
+@property --tw-fade-scrollbar {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+@property --tw-fade-scrollbar-block {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+@property --tw-fade-scrollbar-width {
+ syntax: "";
+ inherits: true;
+ initial-value: 15px;
+}
@property --tw-fade-mask-t {
syntax: "*";
inherits: false;
@@ -990,16 +1005,28 @@
--tw-fade-timeline-b: none;
--tw-fade-animation-range-t: normal;
--tw-fade-animation-range-b: normal;
- mask-image: var(--tw-fade-mask-t, var(--tw-fade-identity-mask)), var(--tw-fade-mask-b, var(--tw-fade-identity-mask)), var(--tw-fade-mask-l, var(--tw-fade-identity-mask)), var(--tw-fade-mask-r, var(--tw-fade-identity-mask));
- mask-composite: intersect;
+ --tw-fade-scrollbar-pos: 100% 0%;
+ --tw-fade-mask-size-l: 100% 100%;
+ --tw-fade-mask-size-r: calc(100% - var(--tw-fade-scrollbar, 0px)) 100%;
+ --tw-fade-mask-pos-l: 0% 0%;
+ --tw-fade-mask-pos-r: 0% 0%;
+ mask-image: var(--tw-fade-identity-mask), var(--tw-fade-identity-mask), var(--tw-fade-mask-t, var(--tw-fade-identity-mask)), var(--tw-fade-mask-b, var(--tw-fade-identity-mask)), var(--tw-fade-mask-l, var(--tw-fade-identity-mask)), var(--tw-fade-mask-r, var(--tw-fade-identity-mask));
+ mask-composite: add, add, intersect, intersect, intersect, intersect;
mask-repeat: no-repeat;
- mask-size: 100% 100%;
+ mask-origin: padding-box, padding-box, border-box, border-box, border-box, border-box;
+ mask-size: 100% var(--tw-fade-scrollbar-block, 0px), var(--tw-fade-scrollbar, 0px) 100%, 100% 100%, 100% calc(100% - var(--tw-fade-scrollbar-block, 0px)), var(--tw-fade-mask-size-l), var(--tw-fade-mask-size-r);
+ mask-position: 0% 100%, var(--tw-fade-scrollbar-pos), 0% 0%, 0% 0%, var(--tw-fade-mask-pos-l), var(--tw-fade-mask-pos-r);
}
.fade:where(:dir(rtl)), .fade-y:where(:dir(rtl)), .fade-top:where(:dir(rtl)), .fade-bottom:where(:dir(rtl)), .fade-x:where(:dir(rtl)), .fade-start:where(:dir(rtl)), .fade-end:where(:dir(rtl)) {
--tw-fade-edge-size-l: var(--tw-fade-size-end, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))));
--tw-fade-edge-size-r: var(--tw-fade-size-start, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))));
--tw-fade-edge-clear-l: var(--tw-fade-clear-end, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px)));
--tw-fade-edge-clear-r: var(--tw-fade-clear-start, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px)));
+ --tw-fade-scrollbar-pos: 0% 0%;
+ --tw-fade-mask-size-l: calc(100% - var(--tw-fade-scrollbar, 0px)) 100%;
+ --tw-fade-mask-size-r: 100% 100%;
+ --tw-fade-mask-pos-l: 100% 0%;
+ --tw-fade-mask-pos-r: 0% 0%;
--tw-fade-mask-l: var(--tw-fade-rtl-end-layer, var(--tw-fade-identity-mask));
--tw-fade-mask-r: var(--tw-fade-rtl-start-layer, var(--tw-fade-identity-mask));
--tw-fade-animation-l: var(--tw-fade-rtl-end-animation, none);
@@ -1099,6 +1126,42 @@
--tw-fade-l: 1 !important;
--tw-fade-r: 1 !important;
}
+ @supports (animation-timeline: scroll()) {
+ .fade-scrollbar-safe-y, .fade-scrollbar-safe-xy {
+ scrollbar-gutter: stable;
+ }
+ @supports selector(::-webkit-scrollbar) {
+ .fade-scrollbar-safe-y, .fade-scrollbar-safe-xy {
+ --tw-fade-scrollbar: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+ .fade-scrollbar-safe-x, .fade-scrollbar-safe-xy {
+ --tw-fade-scrollbar-block: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+ .fade-scrollbar-safe-y::-webkit-scrollbar, .fade-scrollbar-safe-x::-webkit-scrollbar, .fade-scrollbar-safe-xy::-webkit-scrollbar {
+ width: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ height: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+ .fade-scrollbar-safe-y::-webkit-scrollbar-track, .fade-scrollbar-safe-x::-webkit-scrollbar-track, .fade-scrollbar-safe-xy::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ .fade-scrollbar-safe-y::-webkit-scrollbar-thumb, .fade-scrollbar-safe-x::-webkit-scrollbar-thumb, .fade-scrollbar-safe-xy::-webkit-scrollbar-thumb {
+ background: var(--tw-fade-scrollbar-thumb, currentColor);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: var(--tw-fade-scrollbar-thumb, color-mix(in srgb, currentColor 30%, transparent));
+ }
+ border-radius: 999px;
+ }
+ }
+ @supports not selector(::-webkit-scrollbar) {
+ .fade-scrollbar-safe-y, .fade-scrollbar-safe-x, .fade-scrollbar-safe-xy {
+ scrollbar-width: thin;
+ scrollbar-color: var(--tw-fade-scrollbar-thumb, currentColor) transparent;
+ @supports (color: color-mix(in lab, red, red)) {
+ scrollbar-color: var(--tw-fade-scrollbar-thumb, color-mix(in srgb, currentColor 30%, transparent)) transparent;
+ }
+ }
+ }
+ }
}
@property --shimmer-track-height {
syntax: '';
@@ -1280,6 +1343,7 @@
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
:root, :host {
+ --tw-fade-scrollbar-width: 15px;
--shimmer-track-height: 200px;
--shimmer-angle: 15deg;
--glass-bg-opacity: 0.08;
@@ -1335,6 +1399,8 @@
--tw-fade-clear-bottom: initial;
--tw-fade-clear-start: initial;
--tw-fade-clear-end: initial;
+ --tw-fade-scrollbar: 0px;
+ --tw-fade-scrollbar-block: 0px;
--tw-fade-mask-t: initial;
--tw-fade-mask-b: initial;
--tw-fade-mask-l: initial;
diff --git a/dist/tw-fade.css b/dist/tw-fade.css
index ed5226a..a591528 100644
--- a/dist/tw-fade.css
+++ b/dist/tw-fade.css
@@ -190,6 +190,15 @@
.fade-clear-y-xs {
--tw-fade-clear-y: var(--fade-clear-xs);
}
+.fade-scrollbar-width-lg {
+ --tw-fade-scrollbar-width: var(--fade-scrollbar-width-lg);
+}
+.fade-scrollbar-width-md {
+ --tw-fade-scrollbar-width: var(--fade-scrollbar-width-md);
+}
+.fade-scrollbar-width-sm {
+ --tw-fade-scrollbar-width: var(--fade-scrollbar-width-sm);
+}
.fade-size-2xl {
--tw-fade-size: var(--fade-size-2xl);
}
@@ -462,6 +471,21 @@
syntax: "*";
inherits: false;
}
+@property --tw-fade-scrollbar {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+@property --tw-fade-scrollbar-block {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+@property --tw-fade-scrollbar-width {
+ syntax: "";
+ inherits: true;
+ initial-value: 15px;
+}
@property --tw-fade-mask-t {
syntax: "*";
inherits: false;
@@ -567,6 +591,9 @@
--fade-clear-2xl: calc(var(--spacing, 0.25rem) * 24);
--fade-clear-3xl: calc(var(--spacing, 0.25rem) * 32);
--fade-clear-4xl: calc(var(--spacing, 0.25rem) * 40);
+ --fade-scrollbar-width-sm: 11px;
+ --fade-scrollbar-width-md: 15px;
+ --fade-scrollbar-width-lg: 17px;
}
@keyframes tw-fade-travel-t {
from {
@@ -765,16 +792,28 @@
--tw-fade-timeline-b: none;
--tw-fade-animation-range-t: normal;
--tw-fade-animation-range-b: normal;
- mask-image: var(--tw-fade-mask-t, var(--tw-fade-identity-mask)), var(--tw-fade-mask-b, var(--tw-fade-identity-mask)), var(--tw-fade-mask-l, var(--tw-fade-identity-mask)), var(--tw-fade-mask-r, var(--tw-fade-identity-mask));
- mask-composite: intersect;
+ --tw-fade-scrollbar-pos: 100% 0%;
+ --tw-fade-mask-size-l: 100% 100%;
+ --tw-fade-mask-size-r: calc(100% - var(--tw-fade-scrollbar, 0px)) 100%;
+ --tw-fade-mask-pos-l: 0% 0%;
+ --tw-fade-mask-pos-r: 0% 0%;
+ mask-image: var(--tw-fade-identity-mask), var(--tw-fade-identity-mask), var(--tw-fade-mask-t, var(--tw-fade-identity-mask)), var(--tw-fade-mask-b, var(--tw-fade-identity-mask)), var(--tw-fade-mask-l, var(--tw-fade-identity-mask)), var(--tw-fade-mask-r, var(--tw-fade-identity-mask));
+ mask-composite: add, add, intersect, intersect, intersect, intersect;
mask-repeat: no-repeat;
- mask-size: 100% 100%;
+ mask-origin: padding-box, padding-box, border-box, border-box, border-box, border-box;
+ mask-size: 100% var(--tw-fade-scrollbar-block, 0px), var(--tw-fade-scrollbar, 0px) 100%, 100% 100%, 100% calc(100% - var(--tw-fade-scrollbar-block, 0px)), var(--tw-fade-mask-size-l), var(--tw-fade-mask-size-r);
+ mask-position: 0% 100%, var(--tw-fade-scrollbar-pos), 0% 0%, 0% 0%, var(--tw-fade-mask-pos-l), var(--tw-fade-mask-pos-r);
}
.fade:where(:dir(rtl)), .fade-y:where(:dir(rtl)), .fade-top:where(:dir(rtl)), .fade-bottom:where(:dir(rtl)), .fade-x:where(:dir(rtl)), .fade-start:where(:dir(rtl)), .fade-end:where(:dir(rtl)) {
--tw-fade-edge-size-l: var(--tw-fade-size-end, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))));
--tw-fade-edge-size-r: var(--tw-fade-size-start, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))));
--tw-fade-edge-clear-l: var(--tw-fade-clear-end, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px)));
--tw-fade-edge-clear-r: var(--tw-fade-clear-start, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px)));
+ --tw-fade-scrollbar-pos: 0% 0%;
+ --tw-fade-mask-size-l: calc(100% - var(--tw-fade-scrollbar, 0px)) 100%;
+ --tw-fade-mask-size-r: 100% 100%;
+ --tw-fade-mask-pos-l: 100% 0%;
+ --tw-fade-mask-pos-r: 0% 0%;
--tw-fade-mask-l: var(--tw-fade-rtl-end-layer, var(--tw-fade-identity-mask));
--tw-fade-mask-r: var(--tw-fade-rtl-start-layer, var(--tw-fade-identity-mask));
--tw-fade-animation-l: var(--tw-fade-rtl-end-animation, none);
@@ -874,9 +913,48 @@
--tw-fade-l: 1 !important;
--tw-fade-r: 1 !important;
}
+ @supports (animation-timeline: scroll()) {
+ .fade-scrollbar-safe-y, .fade-scrollbar-safe-xy {
+ scrollbar-gutter: stable;
+ }
+ @supports selector(::-webkit-scrollbar) {
+ .fade-scrollbar-safe-y, .fade-scrollbar-safe-xy {
+ --tw-fade-scrollbar: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+ .fade-scrollbar-safe-x, .fade-scrollbar-safe-xy {
+ --tw-fade-scrollbar-block: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+ .fade-scrollbar-safe-y::-webkit-scrollbar, .fade-scrollbar-safe-x::-webkit-scrollbar, .fade-scrollbar-safe-xy::-webkit-scrollbar {
+ width: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ height: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+ .fade-scrollbar-safe-y::-webkit-scrollbar-track, .fade-scrollbar-safe-x::-webkit-scrollbar-track, .fade-scrollbar-safe-xy::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ .fade-scrollbar-safe-y::-webkit-scrollbar-thumb, .fade-scrollbar-safe-x::-webkit-scrollbar-thumb, .fade-scrollbar-safe-xy::-webkit-scrollbar-thumb {
+ background: var(--tw-fade-scrollbar-thumb, currentColor);
+ @supports (color: color-mix(in lab, red, red)) {
+ background: var(--tw-fade-scrollbar-thumb, color-mix(in srgb, currentColor 30%, transparent));
+ }
+ border-radius: 999px;
+ }
+ }
+ @supports not selector(::-webkit-scrollbar) {
+ .fade-scrollbar-safe-y, .fade-scrollbar-safe-x, .fade-scrollbar-safe-xy {
+ scrollbar-width: thin;
+ scrollbar-color: var(--tw-fade-scrollbar-thumb, currentColor) transparent;
+ @supports (color: color-mix(in lab, red, red)) {
+ scrollbar-color: var(--tw-fade-scrollbar-thumb, color-mix(in srgb, currentColor 30%, transparent)) transparent;
+ }
+ }
+ }
+ }
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
+ :root, :host {
+ --tw-fade-scrollbar-width: 15px;
+ }
*, ::before, ::after, ::backdrop {
--tw-fade-t: 0;
--tw-fade-b: 0;
@@ -897,6 +975,8 @@
--tw-fade-clear-bottom: initial;
--tw-fade-clear-start: initial;
--tw-fade-clear-end: initial;
+ --tw-fade-scrollbar: 0px;
+ --tw-fade-scrollbar-block: 0px;
--tw-fade-mask-t: initial;
--tw-fade-mask-b: initial;
--tw-fade-mask-l: initial;
diff --git a/package-lock.json b/package-lock.json
index de5ed1c..6e3f888 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tw-fade",
- "version": "0.7.1",
+ "version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tw-fade",
- "version": "0.7.1",
+ "version": "0.8.0",
"license": "MIT",
"devDependencies": {
"@tailwindcss/cli": "^4.0.0",
diff --git a/package.json b/package.json
index fde0332..ed935da 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "tw-fade",
- "version": "0.7.1",
+ "version": "0.8.0",
"description": "Elegant, CSS-driven scroll-edge fade masking for Tailwind CSS v4. Add fade-y, fade-x, fade-start, or fade-end to any scroll container to fade content into its surface at the edges — scroll-gated with zero JavaScript.",
"keywords": [
"tailwindcss",
@@ -52,7 +52,7 @@
"generate:seo": "node scripts/generate-seo-assets.mjs",
"release": "node scripts/release.mjs",
"test": "node --test",
- "verify": "node build/verify.mjs && node build/verify-horizontal-rtl.mjs && node build/verify-dist.mjs && node build/verify-stale-state.mjs && node build/verify-fade.mjs && node build/verify-demo-assets.mjs && node build/verify-page.mjs && node build/verify-demo-runtime.mjs && node build/verify-favicon.mjs && node build/verify-seo.mjs",
+ "verify": "node build/verify.mjs && node build/verify-horizontal-rtl.mjs && node build/verify-dist.mjs && node build/verify-stale-state.mjs && node build/verify-scrollbar.mjs && node build/verify-fade.mjs && node build/verify-demo-assets.mjs && node build/verify-page.mjs && node build/verify-demo-runtime.mjs && node build/verify-favicon.mjs && node build/verify-seo.mjs",
"prepublishOnly": "node scripts/build-css.mjs && node --test"
},
"peerDependencies": {
diff --git a/scripts/build-css.mjs b/scripts/build-css.mjs
index 784c0a1..e717b8a 100644
--- a/scripts/build-css.mjs
+++ b/scripts/build-css.mjs
@@ -42,7 +42,7 @@ const outFile = path.join(root, 'dist', 'tw-fade.css')
// the prebuilt drop-in.
const SCALE = 'xs,sm,md,lg,xl,2xl,3xl,4xl'
const CLASSES =
- `fade fade-{x,y,top,bottom,start,end} fade-none fade-none-{x,y} fade-always fade-always-{x,y} fade-size-{${SCALE}} fade-size-{x,y,top,bottom,start,end}-{${SCALE}} fade-travel-{${SCALE}} fade-clear-{${SCALE},var} fade-clear-{x,y,top,bottom,start,end}-{${SCALE},var}`
+ `fade fade-{x,y,top,bottom,start,end} fade-none fade-none-{x,y} fade-always fade-always-{x,y} fade-size-{${SCALE}} fade-size-{x,y,top,bottom,start,end}-{${SCALE}} fade-travel-{${SCALE}} fade-clear-{${SCALE},var} fade-clear-{x,y,top,bottom,start,end}-{${SCALE},var} fade-scrollbar-width-{sm,md,lg}`
const BANNER =
'/*! tw-fade — framework-free build for plain HTML / CDN.\n' +
diff --git a/scripts/release-metadata.mjs b/scripts/release-metadata.mjs
new file mode 100644
index 0000000..1815996
--- /dev/null
+++ b/scripts/release-metadata.mjs
@@ -0,0 +1,166 @@
+const STABLE_VERSION = /^\d+\.\d+\.\d+$/
+const RELEASE_DATE = /^\d{4}-\d{2}-\d{2}$/
+
+const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+function requireStableVersion(value, label) {
+ if (!STABLE_VERSION.test(value)) {
+ throw new Error(`${label} must be a stable semver version, got "${value}"`)
+ }
+}
+
+function requireSingleMatch(source, pattern, label) {
+ const matches = [...source.matchAll(pattern)]
+ if (matches.length !== 1) {
+ throw new Error(`${label} must appear exactly once; found ${matches.length}`)
+ }
+ return matches[0]
+}
+
+function updateReadme({ readme, packageName, currentVersion, targetVersion }) {
+ const prefix = `https://unpkg.com/${packageName}@`
+ const suffix = '/dist/tw-fade.css'
+ const pattern = new RegExp(
+ `${escapeRegExp(prefix)}([^/\\s"']+)${escapeRegExp(suffix)}`,
+ 'g',
+ )
+ const matches = [...readme.matchAll(pattern)]
+
+ if (matches.length === 0) {
+ throw new Error('README.md must contain a version-pinned tw-fade unpkg stylesheet URL')
+ }
+
+ const pinnedVersions = [...new Set(matches.map((match) => match[1]))]
+ if (pinnedVersions.length !== 1 || pinnedVersions[0] !== currentVersion) {
+ throw new Error(
+ `README.md unpkg stylesheet URLs must pin the current version ${currentVersion}; ` +
+ `found ${pinnedVersions.join(', ')}`,
+ )
+ }
+
+ return readme.replace(pattern, `${prefix}${targetVersion}${suffix}`)
+}
+
+function updateChangelog({ changelog, currentVersion, targetVersion, date }) {
+ const currentEscaped = escapeRegExp(currentVersion)
+ const targetEscaped = escapeRegExp(targetVersion)
+
+ const unreleasedHeading = requireSingleMatch(
+ changelog,
+ /^## \[Unreleased\][ \t]*$/gm,
+ 'CHANGELOG.md Unreleased heading',
+ )
+ if (new RegExp(`^## \\[${targetEscaped}\\] - `, 'm').test(changelog)) {
+ throw new Error(`CHANGELOG.md already has a ${targetVersion} release section`)
+ }
+
+ const releaseHeadingPattern = /^## \[([^\]]+)\] - (\d{4}-\d{2}-\d{2})[ \t]*$/gm
+ releaseHeadingPattern.lastIndex = unreleasedHeading.index + unreleasedHeading[0].length
+ const currentRelease = releaseHeadingPattern.exec(changelog)
+ if (!currentRelease) {
+ throw new Error('CHANGELOG.md must have a dated release section after Unreleased')
+ }
+ if (currentRelease[1] !== currentVersion) {
+ throw new Error(
+ `the first CHANGELOG.md release after Unreleased must be ${currentVersion}; ` +
+ `found ${currentRelease[1]}`,
+ )
+ }
+
+ const notes = changelog
+ .slice(unreleasedHeading.index + unreleasedHeading[0].length, currentRelease.index)
+ .trim()
+ if (!notes) {
+ throw new Error('CHANGELOG.md Unreleased section has no notes to release')
+ }
+
+ const unreleasedLink = requireSingleMatch(
+ changelog,
+ /^\[Unreleased\]:[ \t]*(\S+)[ \t]*$/gm,
+ 'CHANGELOG.md Unreleased comparison link',
+ )
+ const expectedSuffix = `/compare/v${currentVersion}...HEAD`
+ const currentCompareUrl = unreleasedLink[1]
+ if (!currentCompareUrl.endsWith(expectedSuffix)) {
+ throw new Error(
+ `CHANGELOG.md Unreleased link must compare from v${currentVersion}; found ${currentCompareUrl}`,
+ )
+ }
+ const repositoryUrl = currentCompareUrl.slice(0, -expectedSuffix.length)
+
+ requireSingleMatch(
+ changelog,
+ new RegExp(`^\\[${currentEscaped}\\]:[ \\t]*\\S+[ \\t]*$`, 'gm'),
+ `CHANGELOG.md ${currentVersion} comparison link`,
+ )
+ if (new RegExp(`^\\[${targetEscaped}\\]:`, 'm').test(changelog)) {
+ throw new Error(`CHANGELOG.md already has a ${targetVersion} comparison link`)
+ }
+
+ const nextLinks =
+ `[Unreleased]: ${repositoryUrl}/compare/v${targetVersion}...HEAD\n` +
+ `[${targetVersion}]: ${repositoryUrl}/compare/v${currentVersion}...v${targetVersion}`
+ const headingStart = unreleasedHeading.index
+ const releaseStart = currentRelease.index
+ const withRelease =
+ changelog.slice(0, headingStart) +
+ `## [Unreleased]\n\n## [${targetVersion}] - ${date}\n\n${notes}\n\n` +
+ changelog.slice(releaseStart)
+
+ return withRelease.replace(unreleasedLink[0], nextLinks)
+}
+
+/**
+ * Build every release-file update in memory. Callers should write these results
+ * only after this function returns, so stale or malformed metadata cannot leave
+ * a half-bumped checkout behind.
+ */
+export function prepareReleaseBump({ packageJson, lockfile, readme, changelog, targetVersion, date }) {
+ const currentVersion = packageJson?.version
+ const packageName = packageJson?.name
+
+ requireStableVersion(currentVersion, 'package.json version')
+ requireStableVersion(targetVersion, 'target version')
+ if (!packageName) throw new Error('package.json must have a package name')
+ if (!RELEASE_DATE.test(date)) {
+ throw new Error(`release date must use YYYY-MM-DD, got "${date}"`)
+ }
+ if (targetVersion === currentVersion) {
+ throw new Error(`target version must differ from current version ${currentVersion}`)
+ }
+
+ if (lockfile?.version !== currentVersion) {
+ throw new Error(
+ `package-lock.json version must match package.json (${currentVersion}); ` +
+ `found ${lockfile?.version ?? 'missing'}`,
+ )
+ }
+ if (lockfile?.packages?.['']?.version !== currentVersion) {
+ throw new Error(
+ `package-lock.json packages[""] version must match package.json (${currentVersion}); ` +
+ `found ${lockfile?.packages?.['']?.version ?? 'missing'}`,
+ )
+ }
+
+ const nextReadme = updateReadme({ readme, packageName, currentVersion, targetVersion })
+ const nextChangelog = updateChangelog({
+ changelog,
+ currentVersion,
+ targetVersion,
+ date,
+ })
+
+ return {
+ packageJson: { ...packageJson, version: targetVersion },
+ lockfile: {
+ ...lockfile,
+ version: targetVersion,
+ packages: {
+ ...lockfile.packages,
+ '': { ...lockfile.packages[''], version: targetVersion },
+ },
+ },
+ readme: nextReadme,
+ changelog: nextChangelog,
+ }
+}
diff --git a/scripts/release.mjs b/scripts/release.mjs
index 768e389..106c9f0 100644
--- a/scripts/release.mjs
+++ b/scripts/release.mjs
@@ -3,11 +3,14 @@ import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
+import { prepareReleaseBump } from './release-metadata.mjs'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const packagePath = path.join(root, 'package.json')
const lockfilePath = path.join(root, 'package-lock.json')
+const readmePath = path.join(root, 'README.md')
+const changelogPath = path.join(root, 'CHANGELOG.md')
const usage = `Usage:
npm run release -- [options]
@@ -143,17 +146,26 @@ function parseArgs(argv) {
return options
}
-function updatePackageVersions(version) {
- const pkg = readJson(packagePath)
- pkg.version = version
- writeJson(packagePath, pkg)
-
- if (fs.existsSync(lockfilePath)) {
- const lockfile = readJson(lockfilePath)
- lockfile.version = version
- if (lockfile.packages?.['']) lockfile.packages[''].version = version
- writeJson(lockfilePath, lockfile)
+function updateReleaseFiles(version) {
+ let prepared
+ try {
+ prepared = prepareReleaseBump({
+ packageJson: readJson(packagePath),
+ lockfile: readJson(lockfilePath),
+ readme: fs.readFileSync(readmePath, 'utf8'),
+ changelog: fs.readFileSync(changelogPath, 'utf8'),
+ targetVersion: version,
+ date: new Date().toISOString().slice(0, 10),
+ })
+ } catch (error) {
+ fail(error instanceof Error ? error.message : String(error))
}
+
+ // Every transformation above has succeeded. Only now write any release file.
+ writeJson(packagePath, prepared.packageJson)
+ writeJson(lockfilePath, prepared.lockfile)
+ fs.writeFileSync(readmePath, prepared.readme)
+ fs.writeFileSync(changelogPath, prepared.changelog)
}
function npmPublishArgs({ distTag, dryRun, otp }) {
@@ -213,7 +225,7 @@ if (options.publish) {
if (!options.noBump) {
console.log(`Bumping ${pkg.name} from ${pkg.version} to ${targetVersion}`)
- updatePackageVersions(targetVersion)
+ updateReleaseFiles(targetVersion)
} else {
console.log(`Using existing ${pkg.name} version ${targetVersion}`)
}
@@ -236,7 +248,7 @@ if (options.publish) {
console.log('')
console.log('Recommended git follow-up:')
-console.log(' git add package.json package-lock.json dist/tw-fade.css demo/styles.css')
+console.log(' git add package.json package-lock.json README.md CHANGELOG.md dist/tw-fade.css demo/styles.css')
console.log(` git commit -m "Release ${targetVersion}"`)
console.log(` git tag v${targetVersion}`)
console.log(' git push && git push origin --tags')
diff --git a/src/tw-fade.css b/src/tw-fade.css
index 8fa34ad..a8b1895 100644
--- a/src/tw-fade.css
+++ b/src/tw-fade.css
@@ -87,6 +87,86 @@ fades from leaking size, travel, clearance, mask, or routing state.
syntax: "*";
inherits: false;
}
+/*
+Opt-in scrollbar protection. Width of the opaque mask strip that shields a
+space-consuming (classic) scrollbar's inline-end column from the fade. Default
+0px = inert (no extra coverage), so base fades are byte-for-byte unchanged.
+fade-scrollbar-safe-y / -xy set it; users may also set --tw-fade-scrollbar directly.
+
+Typed , unlike the "*" properties above, because this one is substituted
+into mask-size -- and a single bad component makes the WHOLE declaration invalid
+at computed-value time, so all six layers fall back to `auto`, which on a
+gradient means the full positioning area. An opaque layer then covers the entire
+element and the fade vanishes. A typed property with an initial value can't do
+that: a value that doesn't parse as a (`11` instead of `11px`) is
+dropped at computed-value time and, with inherits:false, the property falls back
+to its initial value. So the failure mode of a typo is an inert strip rather
+than a silently disabled plugin.
+*/
+@property --tw-fade-scrollbar {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+/*
+Block-axis counterpart, for the horizontal bar along the block end. Separate
+from --tw-fade-scrollbar and separately opt-in because the two gutters are NOT
+equally knowable. scrollbar-gutter:stable reserves the inline gutter whether or
+not the box currently overflows, so the inline strip is safe to apply blind.
+It reserves nothing on the block axis: measured across Chromium and WebKit, the
+block gutter is 0 unless content actually overflows horizontally, and no
+scrollbar-gutter value changes that. A blind block strip would therefore paint
+an opaque band over real CONTENT on every vertical-only scroller.
+
+CSS cannot detect horizontal overflow, but the author knows. So this stays 0
+until fade-scrollbar-safe-x / -xy (or a direct declaration) assert it -- the same
+move as pinning the width: declare the one bit that cannot be derived.
+*/
+@property --tw-fade-scrollbar-block {
+ syntax: "";
+ inherits: false;
+ initial-value: 0px;
+}
+/*
+Public knob for the pinned bar size. Registered rather than left as a bare
+custom property so a malformed value degrades to the initial value instead of
+reaching ::-webkit-scrollbar { width: } -- which would drop that
+declaration, un-pin the bar, and leave the strip shielding a column of the
+wrong width.
+
+ still admits negative values. That is harmless for the property itself
+but invalid in mask-size, where one negative layer invalidates the whole six-layer
+declaration and silently removes the fade. Every consumption therefore clamps
+the public value with max(0px, ...) -- the bar and both strips all read the same
+sanitized length, so a negative arbitrary utility, theme token, or direct value
+degrades to an inert 0px bar/strip instead.
+
+inherits:true is REQUIRED here, unlike every other property in this file.
+::-webkit-scrollbar is a pseudo-element, and pseudo-elements only see a custom
+property through inheritance from their originating element. Registered with
+inherits:false the pin silently reads the INITIAL value instead of the author's:
+measured, declaring 24px produced an 11px bar in both Chromium and WebKit while
+the strip -- read from the element itself, where the declaration is visible --
+correctly became 24px. That is a 13px opaque slab over content, which is exactly
+the misalignment the pin exists to make impossible.
+
+Inheriting is also what makes fade-scrollbar-width-* useful on an ancestor --
+set it once on and every opted-in scroller below picks it up. The cost of
+that reach is real and worth stating: a nested scroller that has opted in gets
+retuned by an ancestor's setting, since it inherits the value like any other
+custom property. It is inert on elements that never read it (no fade-scrollbar
+utility => no pin, no strip), so the blast radius is "scrollers you already
+opted in", not "the whole page". Override it on the nested scroller to opt out.
+
+15px matches the macOS classic bar. The pin REPLACES the native bar rather than
+decorating it, so this value is the actual hit target the user gets; defaulting
+below native trades pointer accuracy for looks, which is not a default's call.
+*/
+@property --tw-fade-scrollbar-width {
+ syntax: "";
+ inherits: true;
+ initial-value: 15px;
+}
@property --tw-fade-mask-t {
syntax: "*";
inherits: false;
@@ -193,6 +273,21 @@ fades from leaking size, travel, clearance, mask, or routing state.
--fade-clear-2xl: calc(var(--spacing, 0.25rem) * 24);
--fade-clear-3xl: calc(var(--spacing, 0.25rem) * 32);
--fade-clear-4xl: calc(var(--spacing, 0.25rem) * 40);
+ /*
+ Absolute px, not the spacing ramp the scales above use: a scrollbar is device
+ chrome, not typographic rhythm, and it should not resize with the root font.
+ Three steps, each a real native bar width, because there are only about three
+ defensible widths for a scrollbar.
+
+ Deliberately NOT named thin / auto / none. Those are the literal keywords of
+ the real scrollbar-width property -- which this plugin must never set, since
+ setting it makes Blink discard ::-webkit-scrollbar and cancels the pin. A
+ token named `thin` would read like that property and do something else.
+ Unmatched suffixes emit nothing, so fade-scrollbar-width-thin is inert.
+ */
+ --fade-scrollbar-width-sm: 11px; /* slim, below native, for dense chrome */
+ --fade-scrollbar-width-md: 15px; /* the default; macOS classic */
+ --fade-scrollbar-width-lg: 17px; /* Windows classic / Fluent */
}
@keyframes tw-fade-travel-t {
@@ -317,6 +412,23 @@ fades from leaking size, travel, clearance, mask, or routing state.
@utility fade-clear-end-var {
--tw-fade-clear-end: var(--fade-clear-end, var(--fade-clear-x, var(--fade-clear, 0px)));
}
+/*
+Sets the pinned bar size. A pure setter: on its own it turns nothing on, because
+nothing reads --tw-fade-scrollbar-width unless a fade-scrollbar-safe-* class is.
+That split is deliberate. The property inherits, so its most useful home is an
+ancestor -- to set a house style once. A
+setter that also enabled would pin and gutter the page's own scrollbar as a side
+effect of stating a preference.
+
+[length] only, with no --value(integer) branch. Elsewhere a bare integer means
+spacing steps; here it would read as pixels to anyone typing it, and the two
+answers differ by 4x. Refusing the ambiguous form is better than picking a side.
+Arbitrary negative lengths still compile because they are valid values;
+the scrollbar consumers clamp them to 0px before they reach geometry or masks.
+*/
+@utility fade-scrollbar-width-* {
+ --tw-fade-scrollbar-width: --value(--fade-scrollbar-width-*, [length]);
+}
@layer utilities {
.fade,
@@ -497,14 +609,79 @@ fades from leaking size, travel, clearance, mask, or routing state.
--tw-fade-timeline-b: none;
--tw-fade-animation-range-t: normal;
--tw-fade-animation-range-b: normal;
+ /*
+ Scrollbar-protection strip (opt-in, inert by default). An extra opaque
+ mask layer covering only the inline-end scrollbar column, unioned onto
+ the fade via mask-composite:add. Union can only ADD opacity, so it
+ restores that column to fully opaque -- shielding a classic scrollbar
+ from the fade -- while leaving the content fade untouched. Width defaults
+ to 0px (covers nothing => no-op); fade-scrollbar-safe-y widens it. Position
+ is inline-end (right in LTR; the :dir(rtl) block flips it to left).
+ */
+ --tw-fade-scrollbar-pos: 100% 0%;
+ /*
+ The inline-end gradient is inset by the same width, which fixes a second,
+ quieter symptom of the same root cause. Mask percentages resolve against
+ the border box (gutter included) while content stops at the scrollport
+ edge, so a right-edge ramp authored to reach zero at 100% reaches zero
+ inside the GUTTER -- and over real content it only ever gets
+ (size - gutter)/size of the way down. Measured with a 60px band and the
+ 15px default bar, the last content pixel is still 40/255 opaque, and
+ 161/255 under a 26px band: the fade visibly fails to complete, leaving a
+ hard edge exactly where it should have dissolved. Tighter bands are hit
+ harder, since the gutter eats a larger share of the ramp. Sizing the layer to
+ calc(100% - scrollbar) lands the ramp's end on the content edge instead,
+ and the strip restores the gutter column afterwards.
+
+ This is only expressible because the width is pinned rather than guessed;
+ at the 0px default calc(100% - 0px) is 100% and nothing changes.
+ */
+ --tw-fade-mask-size-l: 100% 100%;
+ --tw-fade-mask-size-r: calc(100% - var(--tw-fade-scrollbar, 0px)) 100%;
+ --tw-fade-mask-pos-l: 0% 0%;
+ --tw-fade-mask-pos-r: 0% 0%;
+ /*
+ Mask layers accumulate bottom-to-top: the LAST listed layer seeds the
+ stack and each earlier layer composites onto what's below it. So the four
+ edge gradients sit at the bottom and intersect among themselves, and the
+ strips must be listed FIRST with add to union onto that intersection --
+ add can only raise opacity, restoring the scrollbar columns. A 0px strip
+ covers nothing, so add contributes nothing and base fades are unchanged.
+ */
mask-image:
+ var(--tw-fade-identity-mask),
+ var(--tw-fade-identity-mask),
var(--tw-fade-mask-t, var(--tw-fade-identity-mask)),
var(--tw-fade-mask-b, var(--tw-fade-identity-mask)),
var(--tw-fade-mask-l, var(--tw-fade-identity-mask)),
var(--tw-fade-mask-r, var(--tw-fade-identity-mask));
- mask-composite: intersect;
+ mask-composite: add, add, intersect, intersect, intersect, intersect;
mask-repeat: no-repeat;
- mask-size: 100% 100%;
+ /*
+ The strips use padding-box origin; the gradients keep the default
+ border-box. The gutter sits between the padding and border edges, so a
+ border-box-anchored strip is pushed outward by the border and straddles
+ it: measured on a 5px border with an 11px bar, the strip covered the
+ border plus only 6 of the 11 gutter pixels, leaving 5px of scrollbar
+ still faded. Padding-box is exactly right because CSS Overflow 3 5.1
+ folds the reserved gutter INTO the padding area for painting purposes,
+ so the padding box's inline-end edge is the gutter's outer edge.
+ */
+ mask-origin: padding-box, padding-box, border-box, border-box, border-box, border-box;
+ mask-size:
+ 100% var(--tw-fade-scrollbar-block, 0px),
+ var(--tw-fade-scrollbar, 0px) 100%,
+ 100% 100%,
+ 100% calc(100% - var(--tw-fade-scrollbar-block, 0px)),
+ var(--tw-fade-mask-size-l),
+ var(--tw-fade-mask-size-r);
+ mask-position:
+ 0% 100%,
+ var(--tw-fade-scrollbar-pos),
+ 0% 0%,
+ 0% 0%,
+ var(--tw-fade-mask-pos-l),
+ var(--tw-fade-mask-pos-r);
}
.fade:where(:dir(rtl)),
@@ -518,6 +695,18 @@ fades from leaking size, travel, clearance, mask, or routing state.
--tw-fade-edge-size-r: var(--tw-fade-size-start, var(--tw-fade-size-x, var(--tw-fade-size, var(--tw-fade-size-default))));
--tw-fade-edge-clear-l: var(--tw-fade-clear-end, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px)));
--tw-fade-edge-clear-r: var(--tw-fade-clear-start, var(--tw-fade-clear-x, var(--tw-fade-clear, 0px)));
+ /*
+ RTL puts the vertical scrollbar on the left, so the shield moves there
+ and the INLINE-END inset swaps to the left layer with it. The inset layer
+ is right-aligned (100% 0%) so it occupies everything to the right of the
+ gutter, putting the ramp's transparent end on the content's left edge.
+ At the 0px default the layer is full-size and the alignment is moot.
+ */
+ --tw-fade-scrollbar-pos: 0% 0%;
+ --tw-fade-mask-size-l: calc(100% - var(--tw-fade-scrollbar, 0px)) 100%;
+ --tw-fade-mask-size-r: 100% 100%;
+ --tw-fade-mask-pos-l: 100% 0%;
+ --tw-fade-mask-pos-r: 0% 0%;
--tw-fade-mask-l: var(--tw-fade-rtl-end-layer, var(--tw-fade-identity-mask));
--tw-fade-mask-r: var(--tw-fade-rtl-start-layer, var(--tw-fade-identity-mask));
--tw-fade-animation-l: var(--tw-fade-rtl-end-animation, none);
@@ -661,4 +850,183 @@ fades from leaking size, travel, clearance, mask, or routing state.
--tw-fade-l: 1 !important;
--tw-fade-r: 1 !important;
}
+
+ /*
+ Opt-in: keep a classic (space-consuming) scrollbar out of the fade.
+
+ Why a mask hits the scrollbar at all: masking applies to the element after it
+ and its descendants are drawn to one temporary canvas, and a "visible
+ scrolling mechanism" is part of that canvas. Filter Effects says so in as
+ many words for filter, CSS Masking says so for clip-path, and mask inherits
+ it structurally. Overlay scrollbars (macOS/iOS/Android default) are composited
+ separately and escape -- which is why this is invisible on a Mac and obvious
+ on Windows.
+
+ Why the strip's width can't be derived: the reserved gutter sits inside the
+ mask's reference boxes by normative reference (CSS Overflow 3 folds it into
+ the padding area for background positioning; CSS Masking defines the mask
+ boxes as the background boxes), so no isolates it. Container
+ query units DO measure it -- 100cqi excludes the gutter -- but cq units
+ resolve against an ancestor container, so only descendants can read them,
+ never the masked element itself. The width is therefore knowable everywhere
+ except where it is needed.
+
+ So it is not derived, it is DECLARED. ::-webkit-scrollbar pins the bar to
+ exactly --tw-fade-scrollbar-width in Blink and WebKit on every platform, and
+ the strip is set from the same custom property, so bar and strip are equal by
+ construction rather than by coincidence. That also survives page zoom: a
+ native bar holds a constant physical size, so its width in CSS px drifts as
+ 1/zoom (15 -> 12 -> 7.5 from 1x to 2x), while two author-declared lengths
+ scale together and stay aligned.
+
+ scrollbar-width and scrollbar-color are deliberately NOT set here: specifying
+ either one makes Blink discard ::-webkit-scrollbar customization entirely
+ (scrollbar-width does the same in WebKit), which would cancel the pin and
+ take the gutter to 0 while leaving a 15px strip sitting on top of content.
+ They are confined to the Gecko branch below, where they are the only levers
+ that exist. The same collision applies to Tailwind's own utilities that set
+ those properties -- scrollbar-thin / -auto / -none for the width, and
+ scrollbar-thumb-* / scrollbar-track-* for the colour (there is no
+ scrollbar-color-* utility) -- so putting any of them on the same element
+ cancels the pin. Use fade-scrollbar-width-* for the size and
+ ::-webkit-scrollbar-track / -thumb for the colours, which is why the width
+ utility exists at all.
+ */
+ /*
+ THE SUFFIX IS THE SCROLL AXIS, NOT THE FADED EDGE. -y is the bar you get
+ from scrolling on y: the VERTICAL bar, at the inline end. -x is the
+ horizontal bar along the block end. This follows overflow-y / overflow-x,
+ which is the model an author already has, and it deliberately does NOT
+ follow this plugin's own fade-y (top+bottom edges) -- the noun differs, and
+ in practice the two agree anyway: a y-scroller wants fade-y and
+ fade-scrollbar-safe-y, and all three axes line up.
+
+ Only -y stabilizes a gutter, because only the inline axis has one to
+ stabilize. -x deliberately omits it: scrollbar-gutter reserves nothing on
+ the block axis, and setting it on an x-only scroller would reserve an inline
+ gutter for a vertical bar the author just said they do not have.
+ */
+ /*
+ THE WHOLE UTILITY REQUIRES SCROLL GATING. Not just the strip -- the pin too.
+
+ Without animation-timeline: scroll() the plugin takes its static fallback,
+ which pins every selected fade ON regardless of whether the container
+ overflows (see the @supports not (animation-timeline: scroll()) block). 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 container whose
+ content does not overflow renders the inline-end fade to fully transparent
+ and then a hard 15px opaque band of content beyond it -- the exact inversion
+ of what this utility is for, on markup that never mentions fade-always.
+
+ Dropping only the strip is not enough, because the pin is what makes the bar
+ classic in the first place. Pinned-but-unshielded is strictly worse than not
+ opting in at all: on macOS it manufactures a space-consuming bar and then
+ lets the fade dim it, converting a platform with no bug into one with the
+ bug. So the entire opt-in stands down together, and those engines get their
+ native behaviour -- overlay bars on macOS, which the mask never touches.
+
+ This costs nothing where the feature works: every engine that can shield a
+ scrollbar (Blink, WebKit 26+) supports scroll-driven animations. It removes
+ the feature on Safari 17.x / 18.x and on release Firefox with the
+ scroll-driven-animation pref off, where it could only have done harm.
+ */
+ @supports (animation-timeline: scroll()) {
+ .fade-scrollbar-safe-y,
+ .fade-scrollbar-safe-xy {
+ /*
+ Required, not cosmetic: without a stable gutter the bar appears only
+ while the content overflows, so the strip would be right when it does
+ and a bright slab over content when it doesn't. Note this only takes
+ effect once the pin below has made the bar classic -- scrollbar-gutter
+ reserves nothing for overlay bars, which consume no space to begin with.
+
+ Blink honours this for custom bars and reserves the gutter even when the
+ content does not overflow. WebKit does not: measured, gutter 0 on a
+ non-overflowing container while the strip is still 15px wide. That
+ residual is documented, and an author can close it with
+ overflow-y: scroll, which forces the gutter in WebKit too (measured 15px,
+ strip landing exactly on the track).
+ */
+ scrollbar-gutter: stable;
+ }
+
+ @supports selector(::-webkit-scrollbar) {
+ .fade-scrollbar-safe-y,
+ .fade-scrollbar-safe-xy {
+ --tw-fade-scrollbar: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+
+ /*
+ The block axis, for the horizontal bar. Only safe when the container
+ really does overflow horizontally: unlike the inline gutter, this one is
+ reserved only while a bar is actually present, and no scrollbar-gutter
+ value changes that -- see the --tw-fade-scrollbar-block registration for
+ the measurements. CSS cannot detect horizontal overflow; the author can.
+ That is the whole reason the axis is named rather than inferred.
+ */
+ .fade-scrollbar-safe-x,
+ .fade-scrollbar-safe-xy {
+ --tw-fade-scrollbar-block: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+
+ /*
+ The pin. Declaring a width here forces a classic, space-consuming bar of
+ exactly this size on every platform -- including macOS, where the default
+ would otherwise be an overlay bar. That is the trade this utility makes:
+ it takes ownership of the scrollbar's geometry in exchange for being
+ exact rather than approximate.
+
+ Both dimensions are pinned on all three classes, even though -y shields
+ only the inline bar. Both engines treat the custom-scrollbar opt-in as
+ per-ELEMENT, not per-axis: an unscoped ::-webkit-scrollbar rule turns the
+ horizontal bar classic too, whether or not this rule mentions height.
+ Omitting height therefore does not restore the overlay bar, it just
+ surrenders the size -- measured, the manufactured horizontal bar grows
+ from 11px to the 16px UA default while remaining equally faded. Pinning
+ it keeps that bar small and, more importantly, keeps it a known quantity
+ that -x and -xy can shield exactly.
+ */
+ .fade-scrollbar-safe-y::-webkit-scrollbar,
+ .fade-scrollbar-safe-x::-webkit-scrollbar,
+ .fade-scrollbar-safe-xy::-webkit-scrollbar {
+ width: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ height: max(0px, var(--tw-fade-scrollbar-width, 15px));
+ }
+
+ .fade-scrollbar-safe-y::-webkit-scrollbar-track,
+ .fade-scrollbar-safe-x::-webkit-scrollbar-track,
+ .fade-scrollbar-safe-xy::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .fade-scrollbar-safe-y::-webkit-scrollbar-thumb,
+ .fade-scrollbar-safe-x::-webkit-scrollbar-thumb,
+ .fade-scrollbar-safe-xy::-webkit-scrollbar-thumb {
+ background: var(--tw-fade-scrollbar-thumb, color-mix(in srgb, currentColor 30%, transparent));
+ border-radius: 999px;
+ }
+ }
+
+ /*
+ Gecko ignores ::-webkit-scrollbar, so its bar cannot be pinned -- and its
+ overlay-vs-classic mode cannot be detected either (there is no media feature
+ for it; @media (-moz-overlay-scrollbars) is dropped as unknown, and no
+ combination of scrollbar-width / -color / -gutter forces a classic bar on
+ macOS). A strip here would be correct on Windows and Linux and a hard opaque
+ slab over content on macOS, with no way to tell which. Since a missed strip
+ damages content while the bug it prevents only dims chrome, a Gecko build
+ that passes the outer scroll-animation gate gets no strip: its bar is merely
+ made thin, and the fade over it remains a documented limitation. Release
+ Firefox fails that gate and skips the whole opt-in, keeping its native bar.
+ */
+ @supports not selector(::-webkit-scrollbar) {
+ .fade-scrollbar-safe-y,
+ .fade-scrollbar-safe-x,
+ .fade-scrollbar-safe-xy {
+ scrollbar-width: thin;
+ scrollbar-color: var(--tw-fade-scrollbar-thumb, color-mix(in srgb, currentColor 30%, transparent))
+ transparent;
+ }
+ }
+ }
}
diff --git a/test/fixtures/release-metadata/CHANGELOG.md b/test/fixtures/release-metadata/CHANGELOG.md
new file mode 100644
index 0000000..7740358
--- /dev/null
+++ b/test/fixtures/release-metadata/CHANGELOG.md
@@ -0,0 +1,21 @@
+# Changelog
+
+## [Unreleased]
+
+### Added
+
+- Added the new release feature.
+
+### Fixed
+
+- Fixed the release bug.
+
+## [1.2.3] - 2026-07-01
+
+### Fixed
+
+- Previous release notes stay in place.
+
+[Unreleased]: https://github.com/example/tw-fade/compare/v1.2.3...HEAD
+[1.2.3]: https://github.com/example/tw-fade/compare/v1.2.2...v1.2.3
+[1.2.2]: https://github.com/example/tw-fade/releases/tag/v1.2.2
diff --git a/test/fixtures/release-metadata/README.md b/test/fixtures/release-metadata/README.md
new file mode 100644
index 0000000..f31e851
--- /dev/null
+++ b/test/fixtures/release-metadata/README.md
@@ -0,0 +1,8 @@
+# tw-fade fixture
+
+```html
+
+```
+
+The pinned URL is repeated here to prove every matching example moves together:
+https://unpkg.com/tw-fade@1.2.3/dist/tw-fade.css
diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs
index 753c13f..cbb3f10 100644
--- a/test/plugin.test.mjs
+++ b/test/plugin.test.mjs
@@ -265,9 +265,23 @@ test('leading and trailing keyframes include RTL horizontal variants', () => {
test('the shared mask setup owns four physical layers and scroll animation wiring', () => {
const shared = block(SHARED_SELECTOR)
assert.ok(shared, 'missing shared direction block')
- assert.equal(declValue(shared, 'mask-composite'), 'intersect')
+ // Four edge gradients (intersect) preceded by two scrollbar-protection strips
+ // (add), block axis then inline axis. The strips are listed first so they
+ // union onto the intersection; both default to 0px and stay inert. See the
+ // mask block in src/tw-fade.css.
+ assert.equal(declValue(shared, 'mask-composite'), 'add, add, intersect, intersect, intersect, intersect')
assert.equal(declValue(shared, 'mask-repeat'), 'no-repeat')
- assert.equal(declValue(shared, 'mask-size'), '100% 100%')
+ assert.equal(
+ compact(declValue(shared, 'mask-size')),
+ '100% var(--tw-fade-scrollbar-block, 0px), var(--tw-fade-scrollbar, 0px) 100%, 100% 100%, ' +
+ '100% calc(100% - var(--tw-fade-scrollbar-block, 0px)), var(--tw-fade-mask-size-l), var(--tw-fade-mask-size-r)',
+ )
+ // The strips anchor to the padding box because the reserved gutter is folded
+ // into the padding area for painting; the gradients keep the border box.
+ assert.equal(
+ compact(declValue(shared, 'mask-origin')),
+ 'padding-box, padding-box, border-box, border-box, border-box, border-box',
+ )
const maskImage = declValue(shared, 'mask-image')
for (const v of MASK_VARS) assert.match(maskImage, new RegExp(`var\\(${v.replaceAll('-', '\\-')}`), `${v} in mask-image`)
@@ -279,6 +293,255 @@ test('the shared mask setup owns four physical layers and scroll animation wirin
assert.equal((declValue(animated, 'animation-range').match(/var\(--tw-fade-animation-range-/g) || []).length, 4)
})
+test('the scrollbar strip is inert until opted in, and typed so a bad value stays inert', () => {
+ const shared = block(SHARED_SELECTOR)
+ assert.equal(declValue(shared, '--tw-fade-scrollbar-pos'), '100% 0%')
+
+ // Typed with an initial value, NOT syntax:"*". This one is
+ // substituted into mask-size, where a single unparseable component invalidates
+ // the whole declaration -- all six layers fall back to `auto`, which on a
+ // gradient covers the full positioning area and silently disables the fade.
+ // The registration makes `11` (no unit) degrade to the 0px initial value.
+ const prop = property('--tw-fade-scrollbar')
+ assert.equal(prop.syntax, '""')
+ assert.equal(prop.inherits, 'false')
+ assert.equal(prop['initial-value'], '0px')
+
+ // RTL moves the vertical scrollbar to the inline-start edge, so the strip and
+ // the inline-end ramp inset both flip with it.
+ const rtl = block(RTL_SELECTOR)
+ assert.equal(declValue(rtl, '--tw-fade-scrollbar-pos'), '0% 0%')
+ assert.equal(compact(declValue(rtl, '--tw-fade-mask-size-l')), 'calc(100% - var(--tw-fade-scrollbar, 0px)) 100%')
+ assert.equal(compact(declValue(rtl, '--tw-fade-mask-size-r')), '100% 100%')
+})
+
+test('fade-scrollbar-safe-y pins the bar geometry rather than guessing it', () => {
+ const safe = block('.fade-scrollbar-safe-y, .fade-scrollbar-safe-xy')
+ assert.ok(safe, 'missing .fade-scrollbar-safe-y rule')
+
+ // Required, not cosmetic: without a reserved gutter the bar exists only while
+ // content overflows, so the strip would land on content when it doesn't.
+ assert.equal(declValue(safe, 'scrollbar-gutter'), 'stable')
+
+ // scrollbar-width / scrollbar-color must NOT be set unconditionally: either
+ // one makes Blink discard ::-webkit-scrollbar customization (scrollbar-width
+ // does the same in WebKit), which cancels the pin and takes the gutter to 0
+ // while leaving the strip sitting on content.
+ assert.equal(declValue(safe, 'scrollbar-width'), undefined)
+ assert.equal(declValue(safe, 'scrollbar-color'), undefined)
+
+ // Blink/WebKit branch: declare the bar width, and size the strip from the very
+ // same custom property so bar and strip agree by construction.
+ const pinned = block(
+ '.fade-scrollbar-safe-y::-webkit-scrollbar, .fade-scrollbar-safe-x::-webkit-scrollbar, ' +
+ '.fade-scrollbar-safe-xy::-webkit-scrollbar',
+ )
+ assert.ok(pinned, 'missing ::-webkit-scrollbar pin')
+ // Fallbacks on every reference, not decoration: --tw-fade-scrollbar-width is
+ // only ever DECLARED by an author, so without @property support (Safari
+ // 15.4-16.3, Firefox < 128) a bare var() is guaranteed-invalid and takes the
+ // whole declaration down with it. In mask-size that silently deletes the fade.
+ assert.equal(declValue(pinned, 'width'), 'max(0px, var(--tw-fade-scrollbar-width, 15px))')
+ // height is pinned too: the custom-scrollbar opt-in is per-element, so an
+ // unscoped ::-webkit-scrollbar rule turns the horizontal bar classic whether
+ // or not height is mentioned. Omitting it surrenders the size (measured: 11px
+ // -> the 16px UA default) without restoring the overlay bar.
+ assert.equal(declValue(pinned, 'height'), 'max(0px, var(--tw-fade-scrollbar-width, 15px))')
+ assert.match(css, /@supports selector\(::-webkit-scrollbar\)/)
+ assert.match(compact(css), /--tw-fade-scrollbar: max\(0px, var\(--tw-fade-scrollbar-width, 15px\)\)/)
+
+ // The public knob is registered, so an unparseable value degrades to 15px;
+ // well-typed negative lengths are clamped at every consumption point below.
+ const width = property('--tw-fade-scrollbar-width')
+ assert.equal(width.syntax, '""')
+ // The pin REPLACES the native bar, so the default is the user's actual hit
+ // target: match the macOS classic bar rather than defaulting to something
+ // slimmer and prettier.
+ assert.equal(width['initial-value'], '15px')
+ // Must INHERIT, unlike everything else here. ::-webkit-scrollbar is a
+ // pseudo-element and only sees a custom property by inheriting it from its
+ // originating element; with inherits:false the pin reads the 15px initial
+ // value while the strip reads the author's, which is a slab over content.
+ assert.equal(width.inherits, 'true')
+
+ // Gecko branch: it ignores ::-webkit-scrollbar and its overlay mode is not
+ // detectable, so it gets a thin styled bar but deliberately NO strip.
+ assert.match(compact(css), /@supports not selector\(::-webkit-scrollbar\)/)
+})
+
+test('the whole opt-in, pin included, sits inside the scroll-gating feature query', () => {
+ // Without animation-timeline: scroll() the plugin takes the static fallback,
+ // which pins every selected fade ON regardless of overflow -- and a faded
+ // container that is not overflowing has no reserved gutter, so the strip lands
+ // on content. Measured in WebKit: a plain overflow-auto fade-y container with
+ // non-overflowing content faded its inline end to fully transparent and then
+ // painted a hard 15px opaque band of content beyond it.
+ //
+ // Gating only the STRIP would be 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. Both move
+ // together or neither does.
+ const gate = '@supports (animation-timeline: scroll())'
+ const bodies = []
+ for (let at = css.indexOf(gate + ' {'); at !== -1; at = css.indexOf(gate + ' {', at + 1)) {
+ bodies.push(block(gate, css.slice(at)))
+ }
+ assert.ok(bodies.length >= 2, 'expected a scroll-gated block for the animations and one for the scrollbars')
+
+ const gated = bodies.filter((b) => b.includes('.fade-scrollbar-safe-y'))
+ assert.equal(gated.length, 1, 'the scrollbar opt-in must live in exactly one scroll-gated block')
+
+ // Everything the opt-in does has to be in there: the gutter reservation, the
+ // strip widths for both axes, and the pin itself.
+ assert.match(gated[0], /scrollbar-gutter:\s*stable/)
+ assert.match(gated[0], /--tw-fade-scrollbar:\s*max\(0px, var\(--tw-fade-scrollbar-width, 15px\)\)/)
+ assert.match(gated[0], /--tw-fade-scrollbar-block:\s*max\(0px, var\(--tw-fade-scrollbar-width, 15px\)\)/)
+ assert.match(gated[0], /::-webkit-scrollbar\b/)
+
+ // ...and nothing the opt-in does may sit outside it. A stray rule here is the
+ // exact regression this test exists for, since no engine we can run reaches
+ // the ungated path.
+ const outside = bodies.reduce((rest, b) => rest.replace(b, ''), css)
+ assert.ok(
+ !/\.fade-scrollbar-safe-[xy]/.test(outside),
+ 'a fade-scrollbar-safe rule escaped the scroll-gating feature query',
+ )
+})
+
+test('the block-axis strip is a separate opt-in, because that gutter is not reservable', () => {
+ // scrollbar-gutter:stable reserves the inline gutter unconditionally, but
+ // measured across Chromium and WebKit the block gutter stays 0 unless content
+ // actually overflows horizontally -- and no scrollbar-gutter value changes
+ // that. A blind block strip would paint over content on vertical-only
+ // scrollers, so it waits for the author to assert horizontal overflow.
+ const prop = property('--tw-fade-scrollbar-block')
+ assert.equal(prop.syntax, '""')
+ assert.equal(prop.inherits, 'false')
+ assert.equal(prop['initial-value'], '0px')
+
+ const webkit = block('@supports selector(::-webkit-scrollbar)')
+ assert.ok(webkit, 'missing webkit support block')
+
+ // -y owns the inline strip and must NOT reach the block axis.
+ const inline = block('.fade-scrollbar-safe-y, .fade-scrollbar-safe-xy', webkit)
+ assert.ok(inline, 'missing inline-axis opt-in block')
+ assert.equal(declValue(inline, '--tw-fade-scrollbar'), 'max(0px, var(--tw-fade-scrollbar-width, 15px))')
+ assert.equal(declValue(inline, '--tw-fade-scrollbar-block'), undefined, '-y must not set the block strip')
+
+ // -x owns the block strip and must NOT reach the inline axis.
+ const blockAxis = block('.fade-scrollbar-safe-x, .fade-scrollbar-safe-xy', webkit)
+ assert.ok(blockAxis, 'missing block-axis opt-in block')
+ assert.equal(
+ declValue(blockAxis, '--tw-fade-scrollbar-block'),
+ 'max(0px, var(--tw-fade-scrollbar-width, 15px))',
+ )
+ assert.equal(declValue(blockAxis, '--tw-fade-scrollbar'), undefined, '-x must not set the inline strip')
+})
+
+test('the axis suffix names the SCROLL axis, and only -y stabilizes a gutter', () => {
+ // The one thing about this API that can be misread. -y is the bar you get from
+ // scrolling on y -- the VERTICAL bar at the inline end -- following overflow-y,
+ // NOT this plugin's own fade-y (which means the top and bottom edges).
+ const gutter = block('.fade-scrollbar-safe-y, .fade-scrollbar-safe-xy')
+ assert.ok(gutter, 'missing the gutter-stabilizing rule')
+ assert.equal(declValue(gutter, 'scrollbar-gutter'), 'stable')
+
+ // -x must never stabilize a gutter. scrollbar-gutter only reserves on the
+ // INLINE axis, so on an x-only scroller it would reserve space for a vertical
+ // bar the author just said is absent -- and Chromium reserves it even with
+ // overflow-y:hidden, which puts the inline strip on content. That notch is
+ // exactly what naming the axis is supposed to avoid; rendering proof is in
+ // build/verify-scrollbar.mjs (sections 4 and 4b, which compare -y vs -x on the
+ // same container).
+ const xOnly = [...css.matchAll(/\n\s*\.fade-scrollbar-safe-x \{([^}]*)\}/g)]
+ for (const m of xOnly) {
+ assert.equal(declValue(m[1], 'scrollbar-gutter'), undefined, '-x must not set scrollbar-gutter')
+ }
+ // And it is genuinely absent from the -x-only selector list, not merely
+ // unmatched by the regex above.
+ assert.equal(
+ /\.fade-scrollbar-safe-x[,\s][^{]*\{[^}]*scrollbar-gutter/.test(css.replace(/-xy/g, '-ZZ')),
+ false,
+ 'scrollbar-gutter reached a selector list containing -x',
+ )
+})
+
+test('scrollbar vars never appear without a fallback, so @property stays optional', () => {
+ // The three scrollbar properties are only ever DECLARED inside
+ // @supports selector(::-webkit-scrollbar) (the first two) or by the author
+ // (the third). Everywhere else their value comes solely from the @property
+ // initial-value. On an engine without @property -- Safari 15.4-16.3, Firefox
+ // < 128 -- a bare var() to an undeclared property is a guaranteed-invalid
+ // value, and since these are substituted into mask-size that takes the WHOLE
+ // declaration down at computed-value time: all six layers fall back to
+ // `auto`, the two 0px strips become full-size opaque layers, `add` paints
+ // over everything, and the fade silently disappears. 0.7.1 rendered fine on
+ // those engines; the strips are what introduced the dependency.
+ //
+ // Rendering proof lives in build/verify-scrollbar.mjs (it strips the
+ // @property blocks and re-renders). This is the cheap build-time guard.
+ const bare = [...css.matchAll(/var\((--tw-fade-scrollbar(?:-block|-width)?)\s*([,)])/g)].filter(
+ (m) => m[2] === ')',
+ )
+ assert.deepEqual(
+ bare.map((m) => m[0]),
+ [],
+ 'every scrollbar var() must carry a fallback, e.g. var(--tw-fade-scrollbar, 0px)',
+ )
+})
+
+test('fade-scrollbar-width-* is a pure setter over an absolute-px scale', () => {
+ // Three steps, real native bar widths. Absolute px, not the spacing ramp the
+ // other scales use: a scrollbar is device chrome and should not resize with
+ // the root font.
+ const theme = block(':root, :host')
+ for (const [name, px] of [['sm', '11px'], ['md', '15px'], ['lg', '17px']]) {
+ assert.equal(declValue(theme, `--fade-scrollbar-width-${name}`), px)
+ const rule = block(`.fade-scrollbar-width-${name}`)
+ assert.ok(rule, `missing .fade-scrollbar-width-${name}`)
+ assert.equal(declValue(rule, '--tw-fade-scrollbar-width'), `var(--fade-scrollbar-width-${name})`)
+ // Sets ONE property and nothing else. The utility is meant to go on
+ // ancestors () so a house style can be
+ // stated once; if it also enabled, it would pin and gutter the page itself.
+ assert.equal(declValue(rule, 'scrollbar-gutter'), undefined)
+ assert.equal(declValue(rule, '--tw-fade-scrollbar'), undefined)
+ assert.equal(declValue(rule, '--tw-fade-scrollbar-block'), undefined)
+ }
+
+ // No --value(integer) branch: elsewhere a bare integer means spacing steps,
+ // here it would read as px, and the two answers differ by 4x.
+ const bare = compileCss({ classes: 'fade-scrollbar-width-4 fade-scrollbar-width-thin' })
+ assert.equal(block('.fade-scrollbar-width-4', bare), null, 'a bare integer must not compile')
+ // And the scale is deliberately not named after scrollbar-width's keywords --
+ // that property must never be set here, since it cancels the pin.
+ assert.equal(block('.fade-scrollbar-width-thin', bare), null, 'must not shadow the real keyword')
+})
+
+test('negative arbitrary scrollbar widths compile but every geometry consumer clamps them to zero', () => {
+ // Tailwind correctly accepts this because -5px is a valid . The
+ // plugin has to sanitize it where the value becomes scrollbar or mask
+ // geometry: a negative mask-size component invalidates the whole six-layer
+ // declaration and silently removes the fade.
+ const negative = compileCss({ classes: 'fade-scrollbar-width-[-5px]' })
+ const setter = block('.fade-scrollbar-width-\\[-5px\\]', negative)
+ assert.ok(setter, 'the negative arbitrary length should compile')
+ assert.equal(declValue(setter, '--tw-fade-scrollbar-width'), '-5px')
+
+ const webkit = block('@supports selector(::-webkit-scrollbar)', negative)
+ const inline = block('.fade-scrollbar-safe-y, .fade-scrollbar-safe-xy', webkit)
+ const blockAxis = block('.fade-scrollbar-safe-x, .fade-scrollbar-safe-xy', webkit)
+ const pinned = block(
+ '.fade-scrollbar-safe-y::-webkit-scrollbar, .fade-scrollbar-safe-x::-webkit-scrollbar, ' +
+ '.fade-scrollbar-safe-xy::-webkit-scrollbar',
+ webkit,
+ )
+ const clamped = 'max(0px, var(--tw-fade-scrollbar-width, 15px))'
+ assert.equal(declValue(inline, '--tw-fade-scrollbar'), clamped)
+ assert.equal(declValue(blockAxis, '--tw-fade-scrollbar-block'), clamped)
+ assert.equal(declValue(pinned, 'width'), clamped)
+ assert.equal(declValue(pinned, 'height'), clamped)
+})
+
test('direction classes select the expected layers and central RTL routing', () => {
const shared = block(SHARED_SELECTOR)
assert.equal(declValue(shared, '--tw-fade-scroll-epsilon'), '0.1px')
diff --git a/test/release-metadata.test.mjs b/test/release-metadata.test.mjs
new file mode 100644
index 0000000..bf46af5
--- /dev/null
+++ b/test/release-metadata.test.mjs
@@ -0,0 +1,67 @@
+/**
+ * Release metadata must move together whenever the package version changes.
+ * Keeping these checks separate gives maintainers a direct fix for each kind
+ * of drift instead of one broad "metadata mismatch" failure.
+ */
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
+const readJson = (file) => JSON.parse(read(file))
+const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+const pkg = readJson('package.json')
+const lockfile = readJson('package-lock.json')
+const readme = read('README.md')
+const changelog = read('CHANGELOG.md')
+const version = pkg.version
+const escapedVersion = escapeRegExp(version)
+
+test('package-lock root versions match package.json', () => {
+ assert.equal(
+ lockfile.version,
+ version,
+ `package-lock.json version must match package.json (${version})`,
+ )
+ assert.equal(
+ lockfile.packages?.['']?.version,
+ version,
+ `package-lock.json packages[""] version must match package.json (${version})`,
+ )
+})
+
+test('README pins the current package version in its unpkg stylesheet example', () => {
+ const pinnedVersions = [
+ ...readme.matchAll(/https:\/\/unpkg\.com\/tw-fade@([^/\s"']+)\/dist\/tw-fade\.css/g),
+ ].map((match) => match[1])
+
+ assert.ok(
+ pinnedVersions.length > 0,
+ 'README.md must include a version-pinned tw-fade unpkg stylesheet URL',
+ )
+ assert.deepEqual(
+ [...new Set(pinnedVersions)],
+ [version],
+ `README.md unpkg stylesheet URLs must pin tw-fade@${version}`,
+ )
+})
+
+test('CHANGELOG has a dated section for the current package version', () => {
+ assert.match(
+ changelog,
+ new RegExp(`^## \\[${escapedVersion}\\] - \\d{4}-\\d{2}-\\d{2}$`, 'm'),
+ `CHANGELOG.md must include "## [${version}] - YYYY-MM-DD"`,
+ )
+})
+
+test('CHANGELOG Unreleased link compares from the current package tag', () => {
+ const expected = `https://github.com/petekp/tw-fade/compare/v${version}...HEAD`
+ const match = changelog.match(/^\[Unreleased\]:\s*(\S+)$/m)
+
+ assert.ok(match, 'CHANGELOG.md must define an [Unreleased] comparison link')
+ assert.equal(match[1], expected, `[Unreleased] must compare from v${version}`)
+})
diff --git a/test/release-script.test.mjs b/test/release-script.test.mjs
new file mode 100644
index 0000000..7f1eb05
--- /dev/null
+++ b/test/release-script.test.mjs
@@ -0,0 +1,97 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { prepareReleaseBump } from '../scripts/release-metadata.mjs'
+
+const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', 'release-metadata')
+const readFixture = (name) => fs.readFileSync(path.join(fixtures, name), 'utf8')
+
+function releaseFixture(overrides = {}) {
+ return {
+ packageJson: { name: 'tw-fade', version: '1.2.3', description: 'fixture' },
+ lockfile: {
+ name: 'tw-fade',
+ version: '1.2.3',
+ lockfileVersion: 3,
+ packages: { '': { name: 'tw-fade', version: '1.2.3' } },
+ },
+ readme: readFixture('README.md'),
+ changelog: readFixture('CHANGELOG.md'),
+ targetVersion: '1.3.0',
+ date: '2026-07-31',
+ ...overrides,
+ }
+}
+
+test('a version bump prepares every release metadata update together', () => {
+ const fixture = releaseFixture()
+ const prepared = prepareReleaseBump(fixture)
+
+ assert.equal(prepared.packageJson.version, '1.3.0')
+ assert.equal(prepared.lockfile.version, '1.3.0')
+ assert.equal(prepared.lockfile.packages[''].version, '1.3.0')
+ assert.doesNotMatch(prepared.readme, /tw-fade@1\.2\.3/)
+ assert.equal(
+ [...prepared.readme.matchAll(/tw-fade@1\.3\.0\/dist\/tw-fade\.css/g)].length,
+ 2,
+ 'every pinned unpkg stylesheet example should be updated',
+ )
+
+ assert.match(
+ prepared.changelog,
+ /## \[Unreleased\]\n\n## \[1\.3\.0\] - 2026-07-31\n\n### Added\n\n- Added the new release feature\./,
+ )
+ assert.match(prepared.changelog, /## \[1\.2\.3\] - 2026-07-01/)
+ assert.match(
+ prepared.changelog,
+ /^\[Unreleased\]: https:\/\/github\.com\/example\/tw-fade\/compare\/v1\.3\.0\.\.\.HEAD$/m,
+ )
+ assert.match(
+ prepared.changelog,
+ /^\[1\.3\.0\]: https:\/\/github\.com\/example\/tw-fade\/compare\/v1\.2\.3\.\.\.v1\.3\.0$/m,
+ )
+ assert.match(
+ prepared.changelog,
+ /^\[1\.2\.3\]: https:\/\/github\.com\/example\/tw-fade\/compare\/v1\.2\.2\.\.\.v1\.2\.3$/m,
+ )
+
+ assert.equal(fixture.packageJson.version, '1.2.3', 'the input package object stays untouched')
+ assert.equal(fixture.lockfile.version, '1.2.3', 'the input lockfile stays untouched')
+})
+
+test('a bump rejects stale inputs before returning any files to write', async (t) => {
+ await t.test('stale package-lock root', () => {
+ const fixture = releaseFixture()
+ fixture.lockfile.packages[''].version = '1.2.2'
+ assert.throws(
+ () => prepareReleaseBump(fixture),
+ /package-lock\.json packages\[""\] version must match package\.json/,
+ )
+ })
+
+ await t.test('stale README pin', () => {
+ const fixture = releaseFixture({
+ readme: readFixture('README.md').replaceAll('tw-fade@1.2.3', 'tw-fade@1.2.2'),
+ })
+ assert.throws(() => prepareReleaseBump(fixture), /must pin the current version 1\.2\.3/)
+ })
+
+ await t.test('empty Unreleased notes', () => {
+ const fixture = releaseFixture({
+ changelog: readFixture('CHANGELOG.md').replace(
+ /## \[Unreleased\][\s\S]*?(?=## \[1\.2\.3\])/,
+ '## [Unreleased]\n\n',
+ ),
+ })
+ assert.throws(() => prepareReleaseBump(fixture), /Unreleased section has no notes/)
+ })
+
+ await t.test('stale Unreleased comparison base', () => {
+ const fixture = releaseFixture({
+ changelog: readFixture('CHANGELOG.md').replace('v1.2.3...HEAD', 'v1.2.2...HEAD'),
+ })
+ assert.throws(() => prepareReleaseBump(fixture), /must compare from v1\.2\.3/)
+ })
+})