Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
95 changes: 94 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
`<body class="fade-scrollbar-width-lg">` 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
Expand Down Expand Up @@ -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
83 changes: 80 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ npm install tw-fade
Use this for plain HTML, CDN usage, or bundlers that import CSS directly.

```html
<link rel="stylesheet" href="https://unpkg.com/tw-fade@0.7.0/dist/tw-fade.css" />
<link rel="stylesheet" href="https://unpkg.com/tw-fade@0.8.0/dist/tw-fade.css" />
```

```js
Expand Down Expand Up @@ -227,6 +227,82 @@ These utilities change only the active fade amount. They do not select edges on
<div class="fade fade-none-x overflow-auto">...</div>
```

## 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
<div class="overflow-y-auto fade-y fade-scrollbar-safe-y">...</div>
```

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
<body class="fade-scrollbar-width-lg">
<!-- every opted-in container below picks up 17px -->
</body>
```

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
<div class="fade-y fade-scrollbar-safe-y overflow-y-auto" style="--tw-fade-scrollbar-thumb: #888">...</div>
```

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 `<body>` the scroll container and keep the surface behind it on `<html>`.
Expand All @@ -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 |
Expand All @@ -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)).

Expand Down Expand Up @@ -352,7 +429,7 @@ Known no-overflow escape hatch:
</div>
```

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

Expand Down
3 changes: 2 additions & 1 deletion build/verify-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading