Scroll-aware edge fades for Tailwind CSS v4. Mask the edges of any scroll container so content dissolves into the surface behind it. All CSS. No runtime JavaScript.
<div class="fade-y h-64 overflow-y-auto">
<!-- long content; top and bottom fade as you scroll -->
</div>The fade appears only when there is content beyond that edge, then recedes again when you reach the start or end.
A static gradient overlay is always on. It dims content even when nothing is hidden beyond the edge, and it sits in front of your content as another layer.
tw-fade uses a CSS mask on the scroll container itself:
- Scroll-aware. The fade is gated by the element's own scroll position.
- No extra DOM. The mask lives on the scroll container.
- Surface-neutral. The mask reveals whatever is behind the element instead of painting a fake background color.
- Composable. Direction, size, travel distance, and clear zones are separate utilities.
- Graceful fallback. Browsers without scroll-driven animations get a static fade instead of a broken mask.
Use this when your app already builds with Tailwind v4. This path supports arbitrary values like fade-size-[2rem].
npm install tw-fade@import "tailwindcss";
@import "tw-fade";Use this for plain HTML, CDN usage, or bundlers that import CSS directly.
<link rel="stylesheet" href="https://unpkg.com/tw-fade@0.7.0/dist/tw-fade.css" />import "tw-fade/css";The prebuilt file includes only the named utility set. Arbitrary bracket values need Tailwind's source path.
If you use an unversioned CDN URL, you receive the latest package and any breaking API changes that come with it. Pin a version when you need stable HTML.
Put the fade utility on the element that actually scrolls.
<!-- Top and bottom -->
<div class="fade-y h-80 overflow-y-auto">...</div>
<!-- All four edges -->
<div class="fade h-80 overflow-auto">...</div>
<!-- Horizontal rail, direction-aware -->
<div class="fade-x overflow-x-auto">...</div>
<!-- Compose single edges -->
<div class="fade-top fade-end h-80 overflow-auto">...</div>
<!-- Tune the band and travel distance -->
<div class="fade-y fade-size-lg fade-travel-xl h-80 overflow-y-auto">...</div>| Utility | Edges |
|---|---|
fade |
all four edges |
fade-y |
top + bottom |
fade-top |
top |
fade-bottom |
bottom |
fade-x |
horizontal start + horizontal end |
fade-start |
horizontal start |
fade-end |
horizontal end |
start and end are horizontal only and follow semantic reading direction. In LTR, start is the left edge and end is the right edge; in RTL, start is the right edge and end is the left edge. For the top or bottom edge use fade-top / fade-bottom — there is no fade-block-start. Use the HTML dir attribute on the scroll container or an ancestor; a CSS-only direction: rtl rule does not trigger RTL routing.
Vertical names stay physical (top / bottom) because the block axis is not affected by text direction. This split — physical vertical, direction-aware horizontal — mirrors how Tailwind itself ships physical top/bottom insets alongside logical ps/pe. See Why plain directions for the naming rationale.
Size controls how thick the fade band is.
| Utility | Affects |
|---|---|
fade-size-* |
all edges |
fade-size-y-* |
top + bottom |
fade-size-x-* |
start + end |
fade-size-top-* |
top |
fade-size-bottom-* |
bottom |
fade-size-start-* |
horizontal start |
fade-size-end-* |
horizontal end |
<div class="fade fade-size-md fade-size-top-2xl overflow-auto">...</div>Resolution is edge, then axis, then global, then the default. For example, top uses fade-size-top-* first, then fade-size-y-*, then fade-size-*.
The default size is capped at min(12%, 3rem), so small scroll areas do not get swallowed by the fade. Named sizes derive from Tailwind's spacing unit, --spacing, with 0.25rem as the fallback.
| Step | Value | Step | Value |
|---|---|---|---|
xs |
1.5rem |
xl |
5rem |
sm |
2rem |
2xl |
6rem |
md |
3rem |
3xl |
8rem |
lg |
4rem |
4xl |
10rem |
You can override the tokens:
@theme {
--fade-size-md: 2.5rem;
}On the Tailwind source path, size accepts named values, lengths, and percentages:
<div class="fade-y fade-size-[15%]">...</div>The prebuilt CSS includes the named sizes only.
Travel distance controls how far you scroll before the soft band eases open to its full width.
| Utility | Affects |
|---|---|
fade-travel-* |
all selected edges |
<div class="fade-y fade-travel-sm overflow-y-auto">...</div>
<div class="fade-y fade-travel-[80px] overflow-y-auto">...</div>A smaller travel distance opens the band faster; a larger one eases it over more scroll. Either way the edge is masked almost immediately — the band's transparency saturates within travel ÷ 8 of scroll, so content is never hard-clipped at the scroll edge while the band is still widening. The travel is purely the cosmetic open-speed; it is safe at any size.
The named travel scale is the same as the size scale (default sm) and can be overridden with --fade-travel-{step}. Arbitrary travel values are source-path only. To change how fast the edge itself goes transparent, set --tw-fade-onset (default 8; higher = snappier).
Clear zones keep a fully opaque strip before the fade starts. Use them for sticky headers, sticky footers, or fixed controls inside the scroll container.
| Utility | Affects |
|---|---|
fade-clear-* |
all edges |
fade-clear-y-* |
top + bottom |
fade-clear-x-* |
start + end |
fade-clear-top-* |
top |
fade-clear-bottom-* |
bottom |
fade-clear-start-* |
horizontal start |
fade-clear-end-* |
horizontal end |
<div class="fade-top fade-clear-top-[56px] h-80 overflow-y-auto">
<header class="sticky top-0 h-14">...</header>
...
</div>Clear utilities accept named values, lengths, percentages, and bare integers on the Tailwind source path. A bare integer maps to --spacing * N, so fade-clear-top-4 is 1rem with the default spacing unit.
The prebuilt CSS includes named clear values and -var forms, not arbitrary values or integer forms.
Use -var when the clear zone depends on runtime layout:
<div class="fade-top fade-clear-top-var" style="--fade-clear-top: 56px">
...
</div>Available forms:
fade-clear-var
fade-clear-y-var
fade-clear-x-var
fade-clear-top-var
fade-clear-bottom-var
fade-clear-start-var
fade-clear-end-varThe fallback chain is edge, then axis, then global, then 0px.
--fade-clear-top: 56px;
--fade-clear-y: 24px;
--fade-clear: 0px;These utilities change only the active fade amount. They do not select edges on their own, so pair them with a direction utility.
| Utility | Effect |
|---|---|
fade-none |
disables all selected fades |
fade-none-y |
disables selected vertical fades |
fade-none-x |
disables selected horizontal fades |
fade-always |
pins all selected fades fully on |
fade-always-y |
pins selected vertical fades fully on |
fade-always-x |
pins selected horizontal fades fully on |
<div class="fade-y fade-always-y overflow-y-auto">...</div>
<div class="fade fade-none-x overflow-auto">...</div>Fade the element that scrolls. For a full-page fade, make <body> the scroll container and keep the surface behind it on <html>.
<html class="h-full overflow-hidden bg-neutral-950">
<body class="fade-y h-full overflow-y-auto bg-transparent">
...
</body>
</html>Two details matter:
<body>must have a fixed height andoverflow-y-auto, otherwise the viewport scrolls instead.- The scroll container should be transparent if you want the mask to reveal the page surface behind it.
@import "tw-fade" |
tw-fade/css |
|
|---|---|---|
| Needs Tailwind v4 | yes | no |
| Direction utilities | yes | yes |
| Named size/travel/clear utilities | 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 |
The prebuilt file is generated from an explicit safelist. It does not include Tailwind Preflight, core Tailwind utilities, arbitrary values, or integer fade-clear-* classes.
| Specifier | Resolves to |
|---|---|
tw-fade |
./src/tw-fade.css |
tw-fade/css |
./dist/tw-fade.css |
tw-fade/dist/tw-fade.css |
./dist/tw-fade.css |
tailwindcss >=4.0.0 is an optional peer dependency. You need it for the source path, but not for the prebuilt CSS.
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.
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).
Inside browsers with scroll-driven animation support:
- vertical fades use
scroll(self y); - horizontal fades use
scroll(self inline); - leading edges reveal from
0to1near the start of scroll; - trailing edges retract from
1to0near the end of scroll.
If an axis cannot scroll, that axis stays at 0, so the fade does not show. This is intentional.
In browsers without scroll-driven animations, selected fades pin fully on as a static fallback.
If you replace the contents of a faded scrollport without replacing the scrollport itself, the browser has to invalidate the element's scroll-driven animation state. For example: a tabbed mobile list keeps one .fade-y node mounted, scrolls a long tab, then swaps in a short tab whose content no longer overflows.
Current tw-fade mitigates this in Chromium. The expected post-swap state is:
scrollTop/scrollLeftis clamped back to0;- the axis no longer overflows;
- the selected fade amounts return to
0.
WebKit can still retain a stale sampled scroll-animation value in this exact shape, especially after collapsing content from the bottom/end of the scroll range. If a user reports a fade that stays visible after a content swap, advise them in this order:
- Upgrade to the latest
tw-fade. - Key/remount the scrollport by content identity when practical.
- If remounting is awkward, restart the fade animation after the content swap.
- If the app already knows the content cannot overflow, add
fade-none-yorfade-none-xfor that state.
React remount pattern:
<div key={activeTabId} className="fade-y overflow-y-auto">
{items.map((item) => (
<ListItem key={item.id} item={item} />
))}
</div>Animation reset helper:
function resetTwFade(el) {
const previous = el.style.animation
el.style.animation = 'none'
void el.offsetHeight
requestAnimationFrame(() => {
el.style.animation = previous
})
}React reset pattern:
const scrollRef = useRef(null)
useLayoutEffect(() => {
if (scrollRef.current) resetTwFade(scrollRef.current)
}, [activeTabId])
return (
<div ref={scrollRef} className="fade-y overflow-y-auto">
{items.map((item) => (
<ListItem key={item.id} item={item} />
))}
</div>
)Known no-overflow escape hatch:
<div className={canOverflow ? 'fade-y overflow-y-auto' : 'fade-y fade-none-y overflow-y-auto'}>
{items.map((item) => (
<ListItem key={item.id} item={item} />
))}
</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). Treat the rest of the --tw-fade-* namespace as internal implementation detail.
Horizontal fades are direction-aware:
<div dir="rtl" class="fade-start overflow-x-auto">...</div>In that example, fade-start selects the right edge because the scroll container is RTL. fade-end selects the left edge. fade-x selects both.
RTL routing follows semantic direction through dir, not a CSS-only direction: rtl declaration. It relies on the :dir() selector, so on the narrow band of Chromium that supports scroll-driven animations but not :dir() (Chrome 115–119), RTL scrollers fall back to LTR edge mapping — see Browser Support. LTR is unaffected.
Vertical fades are unchanged by text direction.
tw-fade is visual only. It does not move content, add scroll behavior, or change focus order. Like any edge fade, it lowers contrast near the masked edge, so keep band sizes modest and use clear zones for sticky controls or critical text.
Three CSS features carry different support floors:
- CSS masking (
mask-image,mask-composite). Interoperable across current evergreen browsers. - Scroll-driven animations (
animation-timeline: scroll()). Shipped by default in every major engine except Firefox release. - The
:dir()selector, used to routestart/endto the correct physical edge under RTL.
| Engine | Masking | Scroll-driven animation | :dir() |
Result |
|---|---|---|---|---|
| Chrome / Edge | 120+ | 115+ | 120+ | Full scroll-gated, direction-aware fade |
| Safari (WebKit) | 15.4+ | 26.0+ | 16.4+ | Full on 26+; static fade fallback below |
| Firefox (release) | 53+ | not by default | 49+ | Static always-on fade |
Scroll-driven fallback. Browsers without scroll-driven animations still render selected fades, but as always-on static fades. Safari 17.x / 18.x and Firefox release get this fallback (Firefox keeps scroll-driven animations behind a flag, default-on only in Nightly).
RTL routing floor. Direction-aware start / end routing additionally needs :dir(). On the narrow band of Chromium that has scroll-driven animations but not :dir() (Chrome 115–119), RTL scrollers fall back to LTR physical-edge mapping — the fade still renders, but start / end map to the wrong side. LTR is unaffected, and that band is effectively gone on auto-updating Chromium.
Older WebKit may also need -webkit-mask-*; run the prebuilt CSS through Autoprefixer if you target it.
Upgrading from 0.6.x? MIGRATING.md is written as a step-by-step
procedure you can hand straight to a coding agent ("upgrade this project to tw-fade
0.7.0 using MIGRATING.md") — ordered renames, the physical→logical RTL caveats to
review by hand, and a one-line grep to verify nothing was missed. It reads fine for
humans too.
npm test
npm run build
npm run build:demo
npm run verify