ScrollBlur
Blurs the edges of a scroll container.
Blurs the edges of a scroll container, so content passes under the boundary out of focus rather than being cut off at it.
A blur says what a fade cannot. A fade takes the content towards the colour behind the scroller, so it only works where that colour is known and flat. A blur takes it out of focus instead, which is true over a photograph, a gradient, or a list of coloured cards.
It is also what belongs under something laid over the scroller — a button, a header, a search field. The content passing beneath stays visible as shape and colour while losing the detail that would compete with the thing on top.
It needs the optional expo-blur. Without it, and for anyone who has Reduce Transparency switched on, the band falls back to the gradient ScrollFade draws.
Installation
ScrollBlur ships with the library — no separate install.
import { ScrollBlur, Item, Button, BottomSheet, PackageIcon } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add scroll-blurUsage
<ScrollBlur size={64}>
<ScrollView showsVerticalScrollIndicator={false}>
{rows.map((row) => (
<Item key={row.id}>
<Item.Content>
<Item.Title>{row.name}</Item.Title>
<Item.Description>{row.summary}</Item.Description>
</Item.Content>
</Item>
))}
</ScrollView>
</ScrollBlur>Examples
A vertical list
The rows go soft as they reach the top and bottom of the viewport, and the band only appears once there is something behind it to blur.
<ScrollBlur size={64}>
<ScrollView showsVerticalScrollIndicator={false}>
{rows.map((row) => (
<Item key={row.id} variant="outline">
<Item.Media variant="icon"><PackageIcon size={16} /></Item.Media>
<Item.Content>
<Item.Title>{row.name}</Item.Title>
<Item.Description>{row.summary}</Item.Description>
</Item.Content>
</Item>
))}
</ScrollView>
</ScrollBlur>A horizontal row of cards
horizontal on the child moves the bands to the leading and trailing edges. Nothing else changes.
<ScrollBlur size={48}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Item.Group orientation="horizontal">
{categories.map((category) => (
<Item key={category.id} orientation="vertical" variant="outline" size="sm" className="w-44">
<Item.Content>
<Item.Title>{category.name}</Item.Title>
<Item.Description>{category.summary}</Item.Description>
</Item.Content>
</Item>
))}
</Item.Group>
</ScrollView>
</ScrollBlur>One edge
edges="end" blurs only the bottom. Use it where the top of the list is already under a header that draws its own boundary — two treatments on one edge read as a mistake.
<ScrollBlur edges="end" size={56}>
<ScrollView>{rows}</ScrollView>
</ScrollBlur>Tuning the ramp
size is how deep the band is, intensity how strong the blur gets at the very edge, layers how many steps it is built from, and tint how far the wash carries the content into the surface.
Raise layers with size: the same six steps spread over a deeper band are six visible bars.
<ScrollBlur size={96} layers={8} intensity={60}>
<ScrollView>{rows}</ScrollView>
</ScrollBlur>Versions
A button over a list, in a sheet
The case the component exists for. A button floating over a scrolling list needs the list to recede under it, and a fade cannot do that — the sheet's surface is one colour and the rows are another, so a gradient towards either one is visible against the other.
The blur band sits between the two: the rows stay legible as shape and colour while losing the detail that would compete with the button.
It goes in BottomSheet.Header, which is outside BottomSheet.Body and therefore stays put while the content scrolls under it. Give the band edges="start" — the bottom of the sheet is an edge the sheet already draws.
Give it the sheet's own surface through color. The band fades towards whatever it is given, and the theme background is a different colour from the sheet it is fading on.
<BottomSheet.Content size="half">
<ScrollBlur edges="start" size={96} layers={7} color={surface} className="flex-1">
<BottomSheet.Body contentContainerClassName="gap-2 pt-16">
{rows.map((row) => (
<Item key={row.id} variant="outline">
<Item.Content>
<Item.Title>{row.name}</Item.Title>
</Item.Content>
</Item>
))}
</BottomSheet.Body>
</ScrollBlur>
{/* Over the band, not in it: the button is the thing in focus. */}
<View className="absolute inset-x-0 top-0 items-center pt-2">
<Button size="sm" className="rounded-full">Follow all</Button>
</View>
</BottomSheet.Content>API Reference
ScrollBlur
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
size | number | 80 | Depth of the blurred band in pixels. |
edges | 'both' | 'start' | 'end' | 'none' | 'both' | Which edges blur. |
orientation | 'horizontal' | 'vertical' | — | Scroll axis. Inferred from the child's horizontal prop when omitted — pass it explicitly for children that scroll horizontally without that prop (a FlatList with horizontal set through contentContainerStyle, say). |
layers | number | 8 | How many blur views make up the ramp. More is smoother and costs more; the band shows visible steps below four, and past eight nobody can tell. |
intensity | number | 56 | Blur strength at the very edge, 0–100. The layers share it between them. |
material | ScrollBlurTint | 'default' | Which way the material tints. Defaults to the app's theme rather than the phone's, so an app running dark on a light phone does not blur light. |
color | string | — | The colour the band fades towards — washed over the blur, and the whole effect where there is no blur to draw. Defaults to the theme's background. Pass the surface the scrollable actually sits on, or the band fades towards a colour that is not there. |
tint | number | 0.95 | How opaque that wash gets at the outer edge, 0 to 1. Lower it to let more of the content show through the far end of the band; 0 leaves the blur bare, along with the seams between its layers. |
fadeInDistance | number | 24 | Distance in pixels over which an edge comes in from clear to full. |
enabled | boolean | true | Set false to render the child with no bands at all. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
The ramp is a stack and a wash
A blur that goes from nothing to full across a band needs a per-pixel blur radius, and there is no such thing on either platform — a blur view has one strength for its whole rectangle.
So the ramp is built out of several of them. Each layer covers a shorter span than the last, measured from the edge, and each blurs what the layer under it has already blurred. The spans are spaced on a curve rather than evenly, which puts most of the layers in the outer third where the blur changes fastest and the steps would otherwise be widest.
That alone is not smooth. Every layer has a hard edge, and a stack of hard edges is a stack of visible lines however many there are. So a gradient of color is washed over the top — opaque at the outer edge, clear at the inner one. It hides the seams, and it is what makes the band read as one material rather than a pile of rectangles: the content goes soft and fades into the surface at once, which is what the eye expects an edge to do.
layers is the count. Six suits a 64-point band; a deeper one wants more. Each layer is a real native view, so this is the knob that costs something — below four the band shows steps, above eight nobody can tell. tint is how opaque the wash gets; 0 leaves the blur bare, along with its seams.
color is not just the fallback
The wash fades towards color, so it matters even when the blur is drawn. Pass the surface the scrollable actually sits on — a sheet, a card, the page. Left on the theme background inside a sheet, the band fades towards a colour that is not there.
It is also the whole effect where there is no blur: expo-blur is an optional peer, and Reduce Transparency is a preference that outranks the design. Both fall back to the gradient alone.
The rest
Orientation is read from the child's horizontal prop. Pass orientation explicitly for scrollables that do not expose it.
An edge only blurs once there is content past it, and neither edge blurs when the content fits inside the viewport — this is correct from the first frame, not just after the first scroll event.
The child becomes a Reanimated animated component. If you need onScroll on it yourself, it has to be an animated handler from useAnimatedScrollHandler — a plain function will not run.
material follows the app's theme rather than the phone's, so an app running dark on a light phone does not blur light. Pass light or dark to fix it.
For a flat, known background, ScrollFade is cheaper: one gradient per edge against this component's four native views.
Public exports
Values: ScrollBlur
Types: ScrollBlurProps, ScrollBlurTint