Kinetic Type Scroll
A phrase whose words each animate on their own slice of a shared scroll progress value. As you scroll, a soft wave travels through the line: each word scales up, gains letter-spacing and optical weight, lifts off the baseline and comes back to full opacity at its peak, then returns to rest.
Every word receives the same scrollYProgress and derives its own window from
its index. The windows deliberately overlap (WINDOW_OVERLAP = 1.2), and
each word's intensity is a triangle that peaks at the centre of its window and
falls to zero at both edges. That overlap is the whole trick: it is what turns a
row of independent pulses into one crest moving through the phrase.
Usage
Pass words as an array and let the phrase sit inside a scrollable container
taller than the phrase itself. If you want to drive the effect from a specific
scroller rather than the window, pass container as a getter —
:container="() => panelRef", never :container="panelRef". A plain binding is
unwrapped by the template and captured as null during setup, after which
nothing re-reads it and useScroll silently falls back to the document.
<template>
<div
ref="panelRef"
class="h-72 w-full max-w-md overflow-y-auto rounded-2xl border border-default bg-muted/20"
>
<div class="flex h-96 items-center">
<MKineticTypeScroll
:container="() => panelRef"
:words="words"
class="px-6 text-3xl font-semibold"
/>
</div>
</div>
</template>
<script setup lang="ts">
const panelRef = ref<HTMLElement | null>(null)
const words = ['Scroll', 'to', 'set', 'the', 'phrase', 'in', 'motion']
</script>
Examples
Aligned to the start, centre or end
The align prop maps to a justify-* class on the wrapper, positioning the
phrase within its container. It is most visible when the container is wider than
the phrase.
align="start"
align="center"
align="end"
<template>
<div class="grid gap-4 md:grid-cols-3">
<div
v-for="sample in samples"
:key="sample.align"
class="rounded-xl border border-default p-4"
>
<p class="mb-2 text-xs uppercase tracking-wide text-dimmed">
align="{{ sample.align }}"
</p>
<MKineticTypeScroll
:words="sample.words"
:align="sample.align"
class="text-2xl font-semibold"
/>
</div>
</div>
</template>
<script setup lang="ts">
const samples = [
{ align: 'start' as const, words: ['Left', 'aligned', 'wave'] },
{ align: 'center' as const, words: ['Centre', 'aligned', 'wave'] },
{ align: 'end' as const, words: ['Right', 'aligned', 'wave'] },
]
</script>
Tuning the wave values
Every range prop is a [rest, peak] tuple. Widen the tuples and tighten stagger
for a dramatic crest, or narrow them for a subtle one. The second example also
sets an explicit offset so the wave plays across a different part of the scroll.
Gentle
Dramatic
<template>
<div class="space-y-6">
<div class="rounded-xl border border-default p-6">
<p class="mb-3 text-sm font-medium text-highlighted">
Gentle
</p>
<MKineticTypeScroll
:words="['Gentle', 'crest', 'values']"
:stagger="0.2"
:scale-range="[1, 1.08]"
:lift-range="[0, -6]"
:tracking-range="[0, 1]"
:dim-range="[0.75, 1]"
class="text-3xl font-semibold"
/>
</div>
<div class="rounded-xl border border-default p-6">
<p class="mb-3 text-sm font-medium text-highlighted">
Dramatic
</p>
<MKineticTypeScroll
:words="['Dramatic', 'crest', 'values']"
:stagger="0.08"
:offset="['start 0.95', 'end 0.4']"
:scale-range="[0.85, 1.3]"
:lift-range="[0, -20]"
:tracking-range="[-1, 4]"
:dim-range="[0.25, 1]"
:weight-range="[300, 900]"
class="text-3xl font-semibold"
/>
</div>
</div>
</template>
Customize
Adjust words, align, stagger and the range tuples live in the playground
below.
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
wordsrequired | string[] | - | The phrase, one string per word. Words are rendered as inline-block
spans in a flex row, so the phrase wraps naturally on narrow containers — each
word keeps its own index even after wrapping. |
align | 'start' | 'center' | 'end' | 'center' | Row alignment of the wrapped phrase, mapped to a justify-* class on the
wrapper. |
container | MaybeComputedElementRef | - | Optional scrollable ancestor that drives the progress. Must be passed as a
getter (:container="() => panelRef") — see the note above. Left undefined,
useScroll measures the nearest scrollable ancestor, which is the document. |
offset | ScrollOffset | ['start 0.9', 'end 0.45'] | Motion scroll-offset tuple forwarded to useScroll, deciding which part of
the container the phrase animates across. |
stagger | number | 0.12 | Fractional offset between each word's active window. Capped internally so the
wave always fits between the WAVE_MARGIN (0.14) head and tail room — a long
phrase compresses its spacing rather than running off the end of the range. |
scaleRange | [number, number] | [0.92, 1.18] | Scale at rest vs. at each word's peak. |
trackingRange | [number, number] | [-0.5, 2.5] | Letter-spacing in px at rest vs. at peak. Converted to a px string at
runtime, so the values are unitless numbers. |
weightRange | [number, number] | [380, 800] | font-variation-settings "wght" at rest vs. at peak, rounded to an integer.
Silently does nothing unless the active font actually has a wght axis — the
docs site runs Public Sans, which is not variable, so this prop is inert there. |
liftRange | [number, number] | [0, -12] | Vertical offset in px at rest vs. at peak. |
dimRange | [number, number] | [0.45, 1] | Opacity at rest vs. at peak. |
class | string | '' | Merged onto the wrapper after the component's own flex, gap and alignment classes. This is where you set the type size and weight. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.kineticTypeScroll in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative flex flex-wrap gap-x-[0.4em] gap-y-[0.18em] |
srOnly | - | sr-only |
Accessibility
The wrapper renders <span class="sr-only">{{ words.join(' ') }}</span> and
marks every animated word aria-hidden="true". The effect therefore
contributes nothing but glyphs to the accessibility tree, and the phrase is read
once, as plain text, by a screen reader. This is deliberate: an
aria-label on the wrapper div would be dropped by assistive tech, which is
why the readable copy is a real element rather than an attribute.
Under prefers-reduced-motion — read via useReducedMotion — each word renders
as a plain span at the peak weight with no travel, no dimming and no spring.
The phrase is fully readable in its settled state.
Performance
- One
useScrollfor the whole phrase. Each word's window is auseTransformslice of that single sharedMotionValue, so scrolling costs one measurement rather than one per word. - Four of the five animated values per word (
scale,tracking,weight,y,opacity) run throughuseSpring, which smooths the scroll-linked values into a moving crest instead of snapping word by word. The spring transition is shared and near-critically damped (bounce: 0). - Animated properties are
transform(scale,y),opacityand text properties —will-change-transformis set on each word so the compositor keeps them on their own layer. - Words are defined as an inline
defineComponentwith a render function rather than a sibling.vuefile, specifically so they are not auto-registered with anMprefix alongside the public component set. - Under reduced motion the branch returns a plain
spanwith a static style — noMotionwrapper, no motion values, no springs instantiated at all.
MIN_WINDOW (0.09), consecutive
windows no longer overlap and you lose the crest — shorten the phrase or split it
into two components rather than raising stagger.Tuning the wave
The wave's geometry is derived, not configured, but two props move it most.
stagger sets the fractional offset between consecutive word peaks, and offset
decides which part of the viewport the phrase animates across. The rest are
peak-vs-rest value pairs — every one is a [rest, peak] tuple.
| Prop | Rest | Peak | What moves |
|---|---|---|---|
scaleRange | 0.92 | 1.18 | Word size, the most legible part of the effect |
trackingRange | -0.5px | 2.5px | Letter-spacing; the widest spread reads as motion |
weightRange | 380 | 800 | wght axis — only if the active font is variable |
liftRange | 0px | -12px | Vertical offset; raise it for a stronger crest |
dimRange | 0.45 | 1 | Opacity; the resting words stay legible at 0.45 |
The default offset is ['start 0.9', 'end 0.45'], not Motion's own
['start start', 'end end']: on an element shorter than its scroller that
default collapses to a near-zero range and the effect sticks at the start. This
default opens the window as the phrase enters the lower part of the viewport
and closes it around the middle, leaving readable time on both sides.