Scroll Reveal
Reveals children with staggered presets as they scroll into view, plus a springy reading progress bar.
Scroll inside this box ↓
Instant
Copy the file, own the code.
Springy
Physics instead of durations.
Themeable
shadcn-vue tokens throughout.
Accessible
Keyboard and reduced motion.
Composable
Small pieces, no global state.
Fast
Motion values, no re-renders.
Motion that earns its place
Each child enters on its own beat, then gets out of the way.
Installation
pnpm dlx shadcn-vue@latest add https://vuelix.dev/r/scroll-reveal.jsonManual installation
Install the dependencies:
npm install motion-vThen copy these files into components/ui/scroll-reveal/ (plus lib/utils.ts if you don't have it):
<script setup lang="ts">
import type { HTMLAttributes, VNode } from 'vue'
import { motion, useReducedMotion } from 'motion-v'
import { Comment, computed, Fragment, Text, useSlots } from 'vue'
import { cn } from '@/lib/utils'
export type ScrollRevealPreset = 'fade-up' | 'blur-in' | 'scale' | 'slide-left' | 'slide-right'
const props = withDefaults(defineProps<{
/** How each child enters. `slide-left` comes in from the left, `slide-right` from the right. */
preset?: ScrollRevealPreset
/** Seconds between consecutive children. */
stagger?: number
/** Seconds before the first child starts. */
delay?: number
/** Travel distance in px for `fade-up` and the slides. */
distance?: number
/** Reveal only the first time; otherwise hide again when scrolled out. */
once?: boolean
/** Fraction of the wrapper that must be visible before it reveals (0 … 1). */
amount?: number
/** Element rendered for the wrapper. */
as?: string
/** Classes for the element wrapped around each child (e.g. `h-full` in a grid). */
itemClass?: HTMLAttributes['class']
class?: HTMLAttributes['class']
}>(), {
preset: 'fade-up',
stagger: 0.08,
delay: 0,
distance: 24,
once: true,
amount: 0.25,
as: 'div',
})
const slots = useSlots()
const reduced = useReducedMotion()
// Every top-level child gets its own motion wrapper so it can be staggered.
// v-for output arrives as a Fragment, so unwrap those and drop comments / whitespace.
function flatten(nodes: VNode[]): VNode[] {
return nodes.flatMap((node) => {
if (node.type === Fragment)
return flatten(node.children as VNode[])
if (node.type === Comment || (node.type === Text && !String(node.children).trim()))
return []
return [node]
})
}
const children = () => flatten(slots.default?.() ?? [])
const container = computed(() => ({
hidden: {},
visible: { transition: { staggerChildren: props.stagger, delayChildren: props.delay } },
}))
const item = computed(() => {
const d = props.distance
const hidden = reduced.value
? { opacity: 0 }
: {
'fade-up': { opacity: 0, y: d },
'blur-in': { opacity: 0, scale: 0.98, filter: 'blur(10px)' },
'scale': { opacity: 0, scale: 0.88 },
'slide-left': { opacity: 0, x: -d * 1.6 },
'slide-right': { opacity: 0, x: d * 1.6 },
}[props.preset]
return {
hidden,
visible: {
opacity: 1,
x: 0,
y: 0,
scale: 1,
filter: 'blur(0px)',
transition: reduced.value
? { duration: 0.4 }
: { type: 'spring', stiffness: 170, damping: 24, mass: 0.9, filter: { duration: 0.5 } },
},
}
})
</script>
<template>
<motion.div
:as="props.as"
:class="cn(props.class)"
initial="hidden"
while-in-view="visible"
:in-view-options="{ once: props.once, amount: props.amount }"
:variants="container"
>
<motion.div
v-for="(child, i) in children()"
:key="child.key ?? i"
:class="cn(props.itemClass)"
:variants="item"
>
<component :is="child" />
</motion.div>
</motion.div>
</template><script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { motion, useReducedMotion, useScroll, useSpring } from 'motion-v'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
/** Scrollable element to track. Defaults to the page. */
container?: HTMLElement | null
/** Edge the bar sticks to. */
position?: 'top' | 'bottom'
/** `fixed` to the viewport, or `absolute` inside the nearest positioned parent. */
fixed?: boolean
class?: HTMLAttributes['class']
}>(), {
container: null,
position: 'top',
fixed: true,
})
const reduced = useReducedMotion()
const { scrollYProgress } = useScroll(() => ({ container: props.container ?? undefined }))
// A light spring smooths out wheel steps; reduced motion follows the scroll exactly.
const smooth = useSpring(scrollYProgress, { stiffness: 220, damping: 32, restDelta: 0.001 })
</script>
<template>
<motion.div
aria-hidden="true"
:class="cn(
'inset-x-0 z-50 h-1 origin-left bg-brand',
props.fixed ? 'fixed' : 'absolute',
props.position === 'top' ? 'top-0' : 'bottom-0',
props.class,
)"
:style="{ scaleX: reduced ? scrollYProgress : smooth }"
/>
</template>export { default as ScrollProgress } from './ScrollProgress.vue'
export { default as ScrollReveal } from './ScrollReveal.vue'
export type { ScrollRevealPreset } from './ScrollReveal.vue'Usage
import { ScrollProgress, ScrollReveal } from '@/components/ui/scroll-reveal'<!-- once, anywhere on the page: a reading progress bar fixed to the top -->
<ScrollProgress />
<ScrollReveal preset="blur-in" :stagger="0.1" class="grid grid-cols-3 gap-4" item-class="h-full">
<FeatureCard v-for="f in features" :key="f.id" v-bind="f" />
</ScrollReveal>Every top-level child of ScrollReveal (including each item of a v-for) is wrapped in its own motion element and revealed in sequence. Style those wrappers with item-class. With prefers-reduced-motion, children only fade.
ScrollProgress tracks the page by default. Pass container to follow a scrollable element instead, and :fixed="false" to position the bar inside it (see the demo).
API
ScrollReveal
| Prop | Type | Default | Description |
|---|---|---|---|
preset | 'fade-up' | 'blur-in' | 'scale' | 'slide-left' | 'slide-right' | 'fade-up' | How each child enters. slide-left comes in from the left. |
stagger | number | 0.08 | Seconds between children. |
delay | number | 0 | Seconds before the first child. |
distance | number | 24 | Travel in px for fade-up and the slides. |
once | boolean | true | Reveal only the first time. |
amount | number | 0.25 | Fraction visible before revealing. |
as | string | 'div' | Wrapper element. |
itemClass | string | — | Classes for each child's wrapper. |
ScrollProgress
| Prop | Type | Default | Description |
|---|---|---|---|
container | HTMLElement | null | null | Scrollable element to track (page when empty). |
position | 'top' | 'bottom' | 'top' | Edge the bar sticks to. |
fixed | boolean | true | fixed to the viewport, or absolute in its parent. |