# ScrollFade

**When to use:** A scrollable box whose content dissolves at the edge it is scrolling past, instead of being cut off mid-line against it. Use it for a bounded list, a long block of text, a chip or tab row that scrolls sideways — anywhere the hard edge of a scroll container makes content look damaged rather than continued. For a whole page section that pins on scroll use [Sticky](Sticky.md); for thousands of rows use [VirtualList](VirtualList.md).

**Keywords:** scroll shadow, scroll indicator, overflow, edge, gradient, mask, cut off, more content, scrollable list, overscroll hint

**Import:** `import { ScrollFade, ScrollFadeAxis } from '@ahrowe/ui'`
**Types:** `import type { ScrollFadeProps } from '@ahrowe/ui'`

`ScrollFade` **is** the scroll container, not a wrapper around one. Give it a bound — a `maxHeight`, a `height`, or a flex parent that constrains it — or it grows to its content and never scrolls.

```tsx
import { ScrollFade, ScrollFadeAxis } from '@ahrowe/ui';

// A bounded list: the bottom fades until you reach the end, the top once you have left it
<ScrollFade style={{ maxHeight: 280 }}>
  {items.map((item) => (
    <Row key={item.id} item={item} />
  ))}
</ScrollFade>

// Sideways, for a chip or tab row
<ScrollFade axis={ScrollFadeAxis.Horizontal} style={{ maxWidth: '100%' }}>
  <ChipContainer inline>
    {tags.map((tag) => (
      <Chip key={tag} text={tag} />
    ))}
  </ChipContainer>
</ScrollFade>

// A longer fade, for larger text
<ScrollFade fadeLength='48px' style={{ maxHeight: 400 }}>
  <Article />
</ScrollFade>

// Both ends faded at all times, no measuring — for a strip whose own padding already
// holds its first and last item clear of the fade
<ScrollFade axis={ScrollFadeAxis.Horizontal} isAlwaysFaded style={{ paddingInline: 24 }}>
  <Thumbnails />
</ScrollFade>
```

**Enums:**

- `ScrollFadeAxis`: `Vertical` (default) | `Horizontal`

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `children` | `ReactNode` | The scrolling content |
| `axis` | `ScrollFadeAxis` | Which way it scrolls (default `Vertical`) |
| `fadeLength` | `string` | How far the fade runs from each edge, any CSS length (default `24px`) |
| `isAlwaysFaded` | `boolean` | Fade both ends whether or not content is hidden there, which skips the measuring entirely (default `false`). Pair it with padding equal to `fadeLength`, so the first and last item start out clear of the fade and only dissolve once scrolled into it — that is how `Carousel` fades its dot strip |
| `ref` | `Ref<HTMLDivElement>` | The scrolling element itself, for `scrollTo` and friends |

Each end is measured independently, so the fade says which way there is more to go: only the bottom at the top of a list, only the top at the end of it, both in between, and neither on content that fits. Nothing fades while it does not scroll, so a `ScrollFade` around short content looks exactly like a plain `div`. Each end eases in and out over 200ms rather than switching, so reaching an edge doesn't snap. Nothing fades on an axis that cannot scroll: override the overflow to `hidden` or `clip` and the fade goes quiet, since there would be no way to reach what it was hiding.

**How it fades:** a `mask-image`, not a gradient overlay. An overlay has to be painted in the container's own background colour, which a reusable component cannot know; a mask works over a card, a sheet or a photo alike.

The mask covers the whole element, which has two consequences worth knowing. Its own `background`, `border` and `border-radius` fade along with the content, so a bordered box should put the border on a wrapper around `ScrollFade` rather than on `ScrollFade` itself:

```tsx
<div className="framed">
  <ScrollFade style={{ maxHeight: 240 }}>{rows}</ScrollFade>
</div>
```

And the scrollbar fades with everything else. That is usually invisible with overlay scrollbars; hide the scrollbar on a strip where it shows.

Under `prefers-contrast: more` the fade switches itself off and the edge goes hard again, since fading text costs contrast at exactly the edge someone may be reading. `fadeLength='0px'` turns it off outright.

In a browser without `mask-image` the fade simply does not appear and the box scrolls normally. In one without `@property` it appears and disappears instantly instead of easing, since an unregistered custom property has no type for the browser to interpolate.

**Theming:** `--scroll-fade-length` sets the same distance as the `fadeLength` prop, and can be set theme-wide via `ThemeProvider` or on any ancestor. The prop wins where both are set. It reaches `ScrollFade` only: `Modal` and `Carousel` fade their own scroll ports from `--modal-scroll-fade` and from a length tuned to the dot strip, and neither hands its length down to a `ScrollFade` nested inside it.

**Slots:** none — `ScrollFade` renders a single element around its children, so `className`/`style` on the root is the whole surface.
