Scroll Fade

A scroll container whose edges fade only where there is more to scroll, animated as you scroll.

<script setup lang="ts">
import { ScrollFade } from '@animate-ui/vue/primitives/effects/scroll-fade'

withDefaults(
  defineProps<{ size?: number | string, shrink?: number | string, blur?: number | string }>(),
  { size: 40, shrink: 0.03, blur: 4 },
)

const RELEASES = [
  { version: '2.4.0', date: 'Oct 2', title: 'Scroll fade', tag: 'New' },
  { version: '2.3.2', date: 'Sep 28', title: 'Tabs keyboard navigation' },
  { version: '2.3.1', date: 'Sep 21', title: 'Highlight follows scrolling' },
  { version: '2.3.0', date: 'Sep 14', title: 'Base UI drawer', tag: 'New' },
  { version: '2.2.4', date: 'Sep 7', title: 'Faster tooltip warm-up' },
  { version: '2.2.3', date: 'Aug 31', title: 'Reduced motion everywhere' },
  { version: '2.2.2', date: 'Aug 24', title: 'React Aria select' },
  { version: '2.2.1', date: 'Aug 17', title: 'Tempo provider' },
  { version: '2.2.0', date: 'Aug 10', title: 'Accordion', tag: 'New' },
  { version: '2.1.0', date: 'Aug 3', title: 'Motion 14' },
  { version: '2.0.3', date: 'Jul 27', title: 'Sliding number decimals' },
  { version: '2.0.2', date: 'Jul 20', title: 'Dialog origin from trigger' },
  { version: '2.0.1', date: 'Jul 13', title: 'Smaller bundle' },
  { version: '2.0.0', date: 'Jul 6', title: 'Drop-in primitives', tag: 'New' },
  { version: '1.9.4', date: 'Jun 29', title: 'Icon loop delay' },
  { version: '1.9.3', date: 'Jun 22', title: 'Ripple on keyboard press' },
]
</script>

<template>
  <div class="w-full max-w-xs overflow-hidden rounded-xl border bg-card">
    <div class="flex items-center justify-between border-b px-4 py-3">
      <h3 class="text-sm font-medium">
        Changelog
      </h3>
      <span class="text-xs text-muted-foreground">{{ RELEASES.length }} releases</span>
    </div>
    <ScrollFade :size="Number(size)" :shrink="Number(shrink)" :blur="Number(blur)" class="h-64">
      <ul class="flex flex-col p-2">
        <li
          v-for="release in RELEASES"
          :key="release.version"
          class="flex items-center gap-3 rounded-md px-2 py-1"
        >
          <span class="w-12 shrink-0 font-mono text-xs text-muted-foreground">{{ release.version }}</span>
          <span class="flex-1 truncate text-sm">{{ release.title }}</span>
          <span
            v-if="release.tag"
            class="rounded-full bg-primary px-1.5 text-[9px] leading-4 font-medium text-primary-foreground"
          >{{ release.tag }}</span>
          <span v-else class="text-xs text-muted-foreground">{{ release.date }}</span>
        </li>
      </ul>
    </ScrollFade>
  </div>
</template>

Installation

npm install @animate-ui/vue motion-v
pnpm add @animate-ui/vue motion-v
yarn add @animate-ui/vue motion-v
bun add @animate-ui/vue motion-v

Usage

<script setup lang="ts">
import { ScrollFade } from '@animate-ui/vue/primitives/effects/scroll-fade';
</script>

<template>
  <ScrollFade class="h-64">
    <ul><!-- … --></ul>
  </ScrollFade>
</template>

orientation="horizontal" fades the left and right edges, "both" all four. With as-child, the child element becomes the scroll container.

To fade a scroll element you don't render yourself (a library's viewport), pass its ref to useScrollFade:

<script setup lang="ts">
import { ref } from 'vue';
import { ScrollAreaViewport } from 'reka-ui';
import { useScrollFade } from '@animate-ui/vue/primitives/effects/scroll-fade';

const viewport = ref<HTMLElement | null>(null);
useScrollFade(viewport, () => ({ size: 32 }));
</script>

<template>
  <ScrollAreaViewport ref="viewport"><!-- … --></ScrollAreaViewport>
</template>

Animation

  • An edge fades only when there is content past it: none at the edge you're at, none at all when nothing overflows. The fade follows the scroll position directly, with no lag: over the first half of size scrolled away from an edge (20px by default), that edge goes from sharp to faded, and back as you return. The fade keeps its depth (size); only how much the edge clears changes.
  • On page load, the fade is already in place: nothing animates in.
  • The items shrink and blur into the fade with it: from 1.75 times size away from a faded edge, each one eases toward 97% (shrink, 0.03) and a 4px blur (blur) as its center nears the edge, following the scroll the same way. Their places are measured when the content changes and once as a scroll starts, never every frame, and they're written once per frame. An item with its own filter keeps it and isn't blurred. The items are the children of the first element holding several (a list's items, through its ul), and a group among them is opened up: a ul, ol, role="list" or role="group", or an element holding one (a section with its title and list), shrinks line by line, never as a whole. They're shrunk with the scale property so their own transform stays.
  • It's a mask-image, so the content itself dissolves: it works on any background, in light and dark.
  • Scrolling never re-renders Vue: the amounts are motion values written to CSS variables (--scroll-fade-top, --scroll-fade-bottom, --scroll-fade-left, --scroll-fade-right, from 0 to 1).
  • When the content changes size without a scroll (items added or removed), the edges ease to their new amount (200ms, transition).
  • Under reduced motion, the fade and the blur follow the scroll position the same way, and jump when the content changes size; nothing shrinks.

API Reference

ScrollFade

PropTypeDefault
as-child?
booleanfalse
orientation?
'vertical' | 'horizontal' | 'both''vertical'
size?
number40
shrink?
number0.03
blur?
number4
transition?
Transition{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }
enabled?
booleantrue
...props?
HTMLAttributes-

useScrollFade

useScrollFade(ref, options) applies the fade to the element of ref. It takes the options of ScrollFade (orientation, size, shrink, blur, transition, enabled) and returns the motion values of each edge (top, bottom, left, right). It doesn't set the overflow: the element must already scroll.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/23/2026