Skip to content

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.json
Manual installation

Install the dependencies:

bash
npm install motion-v

Then copy these files into components/ui/scroll-reveal/ (plus lib/utils.ts if you don't have it):

vue
<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>
vue
<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>
ts
export { default as ScrollProgress } from './ScrollProgress.vue'
export { default as ScrollReveal } from './ScrollReveal.vue'
export type { ScrollRevealPreset } from './ScrollReveal.vue'

Usage ​

ts
import { ScrollProgress, ScrollReveal } from '@/components/ui/scroll-reveal'
vue
<!-- 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 ​

PropTypeDefaultDescription
preset'fade-up' | 'blur-in' | 'scale' | 'slide-left' | 'slide-right''fade-up'How each child enters. slide-left comes in from the left.
staggernumber0.08Seconds between children.
delaynumber0Seconds before the first child.
distancenumber24Travel in px for fade-up and the slides.
oncebooleantrueReveal only the first time.
amountnumber0.25Fraction visible before revealing.
asstring'div'Wrapper element.
itemClassstring—Classes for each child's wrapper.

ScrollProgress ​

PropTypeDefaultDescription
containerHTMLElement | nullnullScrollable element to track (page when empty).
position'top' | 'bottom''top'Edge the bar sticks to.
fixedbooleantruefixed to the viewport, or absolute in its parent.

Designed by Kuldeep Pisda · Released under the MIT License