{"id":51438904,"url":"https://github.com/petekp/tw-fade","last_synced_at":"2026-07-18T13:11:56.835Z","repository":{"id":365382096,"uuid":"1271845704","full_name":"petekp/tw-fade","owner":"petekp","description":"Elegant, CSS-driven scroll-edge fade masking for Tailwind CSS v4. Zero JavaScript.","archived":false,"fork":false,"pushed_at":"2026-07-09T03:46:25.000Z","size":4534,"stargazers_count":75,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-09T14:03:28.543Z","etag":null,"topics":["css","css-mask","fade","mask-image","masking","plugin","scroll","scroll-driven-animations","tailwind","tailwind-plugin","tailwindcss","tailwindcss-plugin","tailwindcss-v4"],"latest_commit_sha":null,"homepage":"https://pete.design/tw-fade","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/petekp.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-17T04:12:38.000Z","updated_at":"2026-07-09T08:00:17.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/petekp/tw-fade","commit_stats":null,"previous_names":["petekp/tw-fade"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/petekp/tw-fade","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petekp%2Ftw-fade","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petekp%2Ftw-fade/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petekp%2Ftw-fade/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petekp%2Ftw-fade/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/petekp","download_url":"https://codeload.github.com/petekp/tw-fade/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petekp%2Ftw-fade/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35618523,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-18T02:00:07.223Z","response_time":61,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["css","css-mask","fade","mask-image","masking","plugin","scroll","scroll-driven-animations","tailwind","tailwind-plugin","tailwindcss","tailwindcss-plugin","tailwindcss-v4"],"created_at":"2026-07-05T10:00:22.283Z","updated_at":"2026-07-18T13:11:56.807Z","avatar_url":"https://github.com/petekp.png","language":"JavaScript","funding_links":[],"categories":["Plugins"],"sub_categories":[],"readme":"# tw-fade\n\nScroll-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.\n\n**[Live demo](https://pete.design/tw-fade)**\n\n```html\n\u003cdiv class=\"fade-y h-64 overflow-y-auto\"\u003e\n  \u003c!-- long content; top and bottom fade as you scroll --\u003e\n\u003c/div\u003e\n```\n\nThe fade appears only when there is content beyond that edge, then recedes again when you reach the start or end.\n\n## Why\n\nA 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.\n\n`tw-fade` uses a CSS mask on the scroll container itself:\n\n- **Scroll-aware.** The fade is gated by the element's own scroll position.\n- **No extra DOM.** The mask lives on the scroll container.\n- **Surface-neutral.** The mask reveals whatever is behind the element instead of painting a fake background color.\n- **Composable.** Direction, size, travel distance, and clear zones are separate utilities.\n- **Graceful fallback.** Browsers without scroll-driven animations get a static fade instead of a broken mask.\n\n## Install\n\n### Tailwind v4 source path\n\nUse this when your app already builds with Tailwind v4. This path supports arbitrary values like `fade-size-[2rem]`.\n\n```bash\nnpm install tw-fade\n```\n\n```css\n@import \"tailwindcss\";\n@import \"tw-fade\";\n```\n\n### Prebuilt CSS\n\nUse this for plain HTML, CDN usage, or bundlers that import CSS directly.\n\n```html\n\u003clink rel=\"stylesheet\" href=\"https://unpkg.com/tw-fade@0.7.0/dist/tw-fade.css\" /\u003e\n```\n\n```js\nimport \"tw-fade/css\";\n```\n\nThe prebuilt file includes only the named utility set. Arbitrary bracket values need Tailwind's source path.\n\nIf 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.\n\n## Usage\n\nPut the fade utility on the element that actually scrolls.\n\n```html\n\u003c!-- Top and bottom --\u003e\n\u003cdiv class=\"fade-y h-80 overflow-y-auto\"\u003e...\u003c/div\u003e\n\n\u003c!-- All four edges --\u003e\n\u003cdiv class=\"fade h-80 overflow-auto\"\u003e...\u003c/div\u003e\n\n\u003c!-- Horizontal rail, direction-aware --\u003e\n\u003cdiv class=\"fade-x overflow-x-auto\"\u003e...\u003c/div\u003e\n\n\u003c!-- Compose single edges --\u003e\n\u003cdiv class=\"fade-top fade-end h-80 overflow-auto\"\u003e...\u003c/div\u003e\n\n\u003c!-- Tune the band and travel distance --\u003e\n\u003cdiv class=\"fade-y fade-size-lg fade-travel-xl h-80 overflow-y-auto\"\u003e...\u003c/div\u003e\n```\n\n## Direction Utilities\n\n| Utility | Edges |\n| --- | --- |\n| `fade` | all four edges |\n| `fade-y` | top + bottom |\n| `fade-top` | top |\n| `fade-bottom` | bottom |\n| `fade-x` | horizontal start + horizontal end |\n| `fade-start` | horizontal start |\n| `fade-end` | horizontal end |\n\n`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.\n\nVertical 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](./MIGRATING.md#why-plain-directions) for the naming rationale.\n\n## Size\n\nSize controls how thick the fade band is.\n\n| Utility | Affects |\n| --- | --- |\n| `fade-size-*` | all edges |\n| `fade-size-y-*` | top + bottom |\n| `fade-size-x-*` | start + end |\n| `fade-size-top-*` | top |\n| `fade-size-bottom-*` | bottom |\n| `fade-size-start-*` | horizontal start |\n| `fade-size-end-*` | horizontal end |\n\n```html\n\u003cdiv class=\"fade fade-size-md fade-size-top-2xl overflow-auto\"\u003e...\u003c/div\u003e\n```\n\nResolution is edge, then axis, then global, then the default. For example, top uses `fade-size-top-*` first, then `fade-size-y-*`, then `fade-size-*`.\n\nThe 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.\n\n| Step | Value | Step | Value |\n| --- | --- | --- | --- |\n| `xs` | `1.5rem` | `xl` | `5rem` |\n| `sm` | `2rem` | `2xl` | `6rem` |\n| `md` | `3rem` | `3xl` | `8rem` |\n| `lg` | `4rem` | `4xl` | `10rem` |\n\nYou can override the tokens:\n\n```css\n@theme {\n  --fade-size-md: 2.5rem;\n}\n```\n\nOn the Tailwind source path, size accepts named values, lengths, and percentages:\n\n```html\n\u003cdiv class=\"fade-y fade-size-[15%]\"\u003e...\u003c/div\u003e\n```\n\nThe prebuilt CSS includes the named sizes only.\n\n## Travel Distance\n\nTravel distance controls how far you scroll before the soft band eases open to its full width.\n\n| Utility | Affects |\n| --- | --- |\n| `fade-travel-*` | all selected edges |\n\n```html\n\u003cdiv class=\"fade-y fade-travel-sm overflow-y-auto\"\u003e...\u003c/div\u003e\n\u003cdiv class=\"fade-y fade-travel-[80px] overflow-y-auto\"\u003e...\u003c/div\u003e\n```\n\nA 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.\n\nThe 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).\n\n## Clear Zones\n\nClear zones keep a fully opaque strip before the fade starts. Use them for sticky headers, sticky footers, or fixed controls inside the scroll container.\n\n| Utility | Affects |\n| --- | --- |\n| `fade-clear-*` | all edges |\n| `fade-clear-y-*` | top + bottom |\n| `fade-clear-x-*` | start + end |\n| `fade-clear-top-*` | top |\n| `fade-clear-bottom-*` | bottom |\n| `fade-clear-start-*` | horizontal start |\n| `fade-clear-end-*` | horizontal end |\n\n```html\n\u003cdiv class=\"fade-top fade-clear-top-[56px] h-80 overflow-y-auto\"\u003e\n  \u003cheader class=\"sticky top-0 h-14\"\u003e...\u003c/header\u003e\n  ...\n\u003c/div\u003e\n```\n\nClear 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.\n\nThe prebuilt CSS includes named clear values and `-var` forms, not arbitrary values or integer forms.\n\n### Dynamic Clear Zones\n\nUse `-var` when the clear zone depends on runtime layout:\n\n```html\n\u003cdiv class=\"fade-top fade-clear-top-var\" style=\"--fade-clear-top: 56px\"\u003e\n  ...\n\u003c/div\u003e\n```\n\nAvailable forms:\n\n```txt\nfade-clear-var\nfade-clear-y-var\nfade-clear-x-var\nfade-clear-top-var\nfade-clear-bottom-var\nfade-clear-start-var\nfade-clear-end-var\n```\n\nThe fallback chain is edge, then axis, then global, then `0px`.\n\n```css\n--fade-clear-top: 56px;\n--fade-clear-y: 24px;\n--fade-clear: 0px;\n```\n\n## Force Or Disable A Fade\n\nThese utilities change only the active fade amount. They do not select edges on their own, so pair them with a direction utility.\n\n| Utility | Effect |\n| --- | --- |\n| `fade-none` | disables all selected fades |\n| `fade-none-y` | disables selected vertical fades |\n| `fade-none-x` | disables selected horizontal fades |\n| `fade-always` | pins all selected fades fully on |\n| `fade-always-y` | pins selected vertical fades fully on |\n| `fade-always-x` | pins selected horizontal fades fully on |\n\n```html\n\u003cdiv class=\"fade-y fade-always-y overflow-y-auto\"\u003e...\u003c/div\u003e\n\u003cdiv class=\"fade fade-none-x overflow-auto\"\u003e...\u003c/div\u003e\n```\n\n## Fading The Whole Page\n\nFade the element that scrolls. For a full-page fade, make `\u003cbody\u003e` the scroll container and keep the surface behind it on `\u003chtml\u003e`.\n\n```html\n\u003chtml class=\"h-full overflow-hidden bg-neutral-950\"\u003e\n  \u003cbody class=\"fade-y h-full overflow-y-auto bg-transparent\"\u003e\n    ...\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\nTwo details matter:\n\n1. `\u003cbody\u003e` must have a fixed height and `overflow-y-auto`, otherwise the viewport scrolls instead.\n2. The scroll container should be transparent if you want the mask to reveal the page surface behind it.\n\n## Source Path Vs. Prebuilt\n\n| | `@import \"tw-fade\"` | `tw-fade/css` |\n| --- | --- | --- |\n| Needs Tailwind v4 | yes | no |\n| Direction utilities | yes | yes |\n| Named size/travel/clear utilities | yes | yes |\n| `fade-clear-*-var` | yes | yes |\n| Arbitrary values like `fade-size-[6rem]` | yes | no |\n| Integer clear values like `fade-clear-top-14` | yes | no |\n\nThe prebuilt file is generated from an explicit safelist. It does not include Tailwind Preflight, core Tailwind utilities, arbitrary values, or integer `fade-clear-*` classes.\n\n## Exports\n\n| Specifier | Resolves to |\n| --- | --- |\n| `tw-fade` | `./src/tw-fade.css` |\n| `tw-fade/css` | `./dist/tw-fade.css` |\n| `tw-fade/dist/tw-fade.css` | `./dist/tw-fade.css` |\n\n`tailwindcss \u003e=4.0.0` is an optional peer dependency. You need it for the source path, but not for the prebuilt CSS.\n\n## How It Works\n\nEach 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.\n\nThe 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)).\n\nInside browsers with scroll-driven animation support:\n\n- vertical fades use `scroll(self y)`;\n- horizontal fades use `scroll(self inline)`;\n- leading edges reveal from `0` to `1` near the start of scroll;\n- trailing edges retract from `1` to `0` near the end of scroll.\n\nIf an axis cannot scroll, that axis stays at `0`, so the fade does not show. This is intentional.\n\nIn browsers without scroll-driven animations, selected fades pin fully on as a static fallback.\n\n### Dynamic content on a stable scrollport\n\nIf 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.\n\nCurrent `tw-fade` mitigates this in Chromium. The expected post-swap state is:\n\n- `scrollTop` / `scrollLeft` is clamped back to `0`;\n- the axis no longer overflows;\n- the selected fade amounts return to `0`.\n\nWebKit 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:\n\n1. Upgrade to the latest `tw-fade`.\n2. Key/remount the scrollport by content identity when practical.\n3. If remounting is awkward, restart the fade animation after the content swap.\n4. If the app already knows the content cannot overflow, add `fade-none-y` or `fade-none-x` for that state.\n\nReact remount pattern:\n\n```tsx\n\u003cdiv key={activeTabId} className=\"fade-y overflow-y-auto\"\u003e\n  {items.map((item) =\u003e (\n    \u003cListItem key={item.id} item={item} /\u003e\n  ))}\n\u003c/div\u003e\n```\n\nAnimation reset helper:\n\n```js\nfunction resetTwFade(el) {\n  const previous = el.style.animation\n  el.style.animation = 'none'\n  void el.offsetHeight\n  requestAnimationFrame(() =\u003e {\n    el.style.animation = previous\n  })\n}\n```\n\nReact reset pattern:\n\n```tsx\nconst scrollRef = useRef(null)\n\nuseLayoutEffect(() =\u003e {\n  if (scrollRef.current) resetTwFade(scrollRef.current)\n}, [activeTabId])\n\nreturn (\n  \u003cdiv ref={scrollRef} className=\"fade-y overflow-y-auto\"\u003e\n    {items.map((item) =\u003e (\n      \u003cListItem key={item.id} item={item} /\u003e\n    ))}\n  \u003c/div\u003e\n)\n```\n\nKnown no-overflow escape hatch:\n\n```tsx\n\u003cdiv className={canOverflow ? 'fade-y overflow-y-auto' : 'fade-y fade-none-y overflow-y-auto'}\u003e\n  {items.map((item) =\u003e (\n    \u003cListItem key={item.id} item={item} /\u003e\n  ))}\n\u003c/div\u003e\n```\n\nThe 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.\n\n## RTL\n\nHorizontal fades are direction-aware:\n\n```html\n\u003cdiv dir=\"rtl\" class=\"fade-start overflow-x-auto\"\u003e...\u003c/div\u003e\n```\n\nIn that example, `fade-start` selects the right edge because the scroll container is RTL. `fade-end` selects the left edge. `fade-x` selects both.\n\nRTL 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](#browser-support). LTR is unaffected.\n\nVertical fades are unchanged by text direction.\n\n## Accessibility\n\n`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.\n\n## Browser Support\n\nThree CSS features carry different support floors:\n\n- **CSS masking** (`mask-image`, `mask-composite`). Interoperable across current evergreen browsers.\n- **Scroll-driven animations** (`animation-timeline: scroll()`). Shipped by default in every major engine except Firefox release.\n- **The `:dir()` selector**, used to route `start` / `end` to the correct physical edge under RTL.\n\n| Engine | Masking | Scroll-driven animation | `:dir()` | Result |\n| --- | --- | --- | --- | --- |\n| Chrome / Edge | 120+ | 115+ | 120+ | Full scroll-gated, direction-aware fade |\n| Safari (WebKit) | 15.4+ | 26.0+ | 16.4+ | Full on 26+; static fade fallback below |\n| Firefox (release) | 53+ | not by default | 49+ | Static always-on fade |\n\n**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).\n\n**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.\n\n**Older WebKit** may also need `-webkit-mask-*`; run the prebuilt CSS through Autoprefixer if you target it.\n\n## Migrating\n\nUpgrading from `0.6.x`? [MIGRATING.md](./MIGRATING.md) is written as a step-by-step\nprocedure you can hand straight to a coding agent (\"upgrade this project to tw-fade\n0.7.0 using MIGRATING.md\") — ordered renames, the physical→logical RTL caveats to\nreview by hand, and a one-line grep to verify nothing was missed. It reads fine for\nhumans too.\n\n## Development\n\n```bash\nnpm test\nnpm run build\nnpm run build:demo\nnpm run verify\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpetekp%2Ftw-fade","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpetekp%2Ftw-fade","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpetekp%2Ftw-fade/lists"}