Road trip
Coast to Canyon
4 stops · 620 km · 5 days
Scroll sideways, or Shift + scroll
Try it
The numbered controls work. The greyed ones fill out the page and do nothing.
- Scroll the route panel sideways. The car follows the road and turns with every bend, and a trail draws in behind it. It reverses when you scroll back. Each stop's marker fills in as the car reaches it.
- Click a stop card to scroll the panel to that stop, and the car drives there. The card of the last stop reached is highlighted, and a check pops in on every stop the car has passed.
- Book trip runs the booking. The status line scrambles into each step's name next to a spinner, and finished steps slide up into the log. Pause stops the spinner and the step timer, and Resume carries on from the same point. A check pops in when the booking is done.
Reset in the title bar scrolls the route back to the start, cancels the booking and plays the title in again.
Built with
useAnimeScroll: the panel's horizontal scroll, driving the car and the trailuseAnimate: the car on its motion path, the trail, and the title's letters rising into placecreateMotionPathandcreateDrawablefrom#nanime/proxies/svg: the car's path and heading, and the trail's line drawinguseSplitText: the trip title, split into lettersuseScrambleText: the booking status lineuseWaapiAnimate: the spinner<AnimeTransitionGroup>: the booking log<AnimeTransition>: the stop checks, the spinner swapping to a check, and the paused badgespringfrom#nanime/easingsandstaggerfrom#nanime/utils
Layout. The window is split into a header, the route panel, the stop cards and the booking summary. The route panel reports its scroll progress to the parent, and the stop cards read it. The parent also calls the route panel to scroll to a stop, and resets the booking and the header.
Route. The road is one SVG path, drawn four times: a wide edge, the
asphalt, a dashed centre line and a trail. The car is an SVG group moved
along the asphalt path by useAnimate with
createMotionPath(path). The proxy returns translateX, translateY and
rotate values that follow the path, so the car turns with each bend. The
trail is the same path wrapped in createDrawable, animated with
draw: ['0 0', '0 1'].
Neither animation plays on a clock.
useAnimeScroll creates a scroll
observer on the panel with axis: 'x', container set to the panel and
target set to the wide scene inside it. Passing that observer as each
animation's autoplay ties the animation's progress to the scroll
position. sync: true keeps it locked there with no smoothing, and the
ease: 'linear' on the car keeps its position proportional to the scroll.
Each animation gets its own observer, since an observer drives one
animation.
Stops. Each stop has an at value, the fraction of the road where it
sits. On mount, getPointAtLength on the road path places a marker at
each fraction. The car is at the same fraction of the road as the panel is
of its scroll, so a stop is reached when the observer's progress passes
its at. Clicking a card scrolls the panel to at times its scroll range
with scrollTo({ behavior: 'smooth' }), and the scroll observer moves the
car there. The check on each card sits in an
<AnimeTransition> with a small spring()
pop, so it appears as the car passes the stop and leaves when it reverses
past it.
Title. useSplitText splits the title
into letters with wrap: 'clip', so each letter sits in a clipped box.
useAnimate moves each letter from y: '100%'
to 0 with stagger(18), so they rise into place one after another. The
header is keyed on a counter that Reset increments, which remounts it and
plays the title again.
Booking. A useBooking composable steps through a list of steps on a
setTimeout. Each step writes its text to the status line, which
useScrambleText watches, so a new text
starts a new scramble. The wait for each step is the scramble's own length
plus the step's delay, so the text always finishes resolving before the
next one starts. Pause clears the timer and Resume schedules the next step
again.
The spinner turns with
useWaapiAnimate, a looping rotate on
the compositor thread, so it keeps turning while the main thread works.
Pause and Resume call its pause() and play(), so it stops at its
current angle and continues from there. The last three finished steps show
in an <AnimeTransitionGroup>. New steps fade
up, and the oldest fades out as the rest move up. The spinner, the final
check and the idle caret swap inside an <AnimeTransition mode="out-in">.
Using the nanime Nuxt module (AnimeJS v4 for Vue, with Tailwind CSS v4 and
Nuxt UI), build a trip planner page in Vue 3 single-file components.
useAnimate, useAnimeScroll, useSplitText, useScrambleText,
useWaapiAnimate, <AnimeTransition> and <AnimeTransitionGroup> are
auto-imported by nanime. Import createMotionPath and createDrawable from
'#nanime/proxies/svg', spring from '#nanime/easings', stagger from
'#nanime/utils', and the types AnimationParams, DrawableSVGGeometry,
ScrollObserver and ScrollObserverParams from '#nanime/types'. Use strict
TypeScript with no `any` and no `as` casts. Ease names are AnimeJS v4
strings such as 'out(3)', not 'easeOutCubic'. Keep motion subtle: ease out
for things already on screen, and a small spring overshoot only for things
that pop in.
Layout: a window with a title bar (Reset button), a header, and below it a
two-column grid: the route panel and stop cards on the left, a booking
summary on the right (18rem wide on large screens, stacked on small ones).
Header: an <h2> "Coast to Canyon" and a line "4 stops · 620 km · 5 days",
plus three disabled buttons (dates, travellers, share). Split the title
with useSplitText(title, { chars: { wrap: 'clip' } }) and animate the
returned chars with useAnimate(chars, { y: ['100%', '0%'], duration: 600,
delay: stagger(18), ease: 'out(3)' }). Fade the line in with
useAnimate(meta, { opacity: [0, 1], y: [6, 0], duration: 400, delay: 350,
ease: 'out(3)' }). Key the header on a counter that Reset increments, so
Reset replays the title.
Route panel: a div (template ref "viewport") with "overflow-x-auto
overscroll-x-contain", holding a "w-max" div (ref "scene") with an SVG
2600 x 300. The road path is:
M 100 160 C 380 40 620 40 900 150 S 1340 262 1620 152 S 2040 48 2300 142 S 2470 186 2520 168
Draw it four times with fill="none" and stroke-linecap="round": a 52-wide
edge, a 44-wide asphalt (ref "road"), a 2-wide dashed centre line
(stroke-dasharray "18 22") and a 2.5-wide trail (ref "trail"). Add a car
as an SVG <g ref="car"> with CSS transform-box: fill-box and
transform-origin: 50% 50%, drawn pointing along +x.
Scroll: write a helper that returns useAnimeScroll({ container: viewport,
target: scene, axis: 'x', enter: 'left left', leave: 'right right', sync:
true, ...extra }), passing the template refs themselves. Call it
twice, since each observer drives one animation: once for the car with
onUpdate reading observer.progress, once for the trail.
- Car: useAnimate(car, computed(() => road.value ? {
...createMotionPath(road.value), ease: 'linear', autoplay: carScroll } :
{})).
- Trail: a computed createDrawable(trail.value)[0] ?? null, animated with
useAnimate([trailDrawable], { draw: ['0 0', '0 1'], ease: 'linear',
autoplay: trailScroll }).
Emit the progress to the parent.
Stops: four stops { name, day, note, at }, with at = 0, 0.34, 0.66 and 1,
the fraction of the road where each sits. On mount, place an SVG marker
circle and label at road.getPointAtLength(road.getTotalLength() * at).
A stop is reached when progress >= at - 0.01. Fill its marker when
reached. Below the panel, a grid of four stop cards (2 columns on
phones). The card of the last reached stop gets a primary border. Each
card has a check icon inside <AnimeTransition :enter-animation="{ opacity:
[0, 1], scale: [0.6, 1], ease: spring({ bounce: 0.2, duration: 300 }) }">
shown with v-if when the stop is reached. Clicking a card calls
viewport.scrollTo({ left: at * (scrollWidth - clientWidth), behavior:
'smooth' }), and the scroll observer moves the car.
Booking summary: price lines and a total, a disabled "Change car" button,
a fixed-height log box and the controls. Keep the booking in a
useBooking() composable: state ('idle' | 'running' | 'done'), paused,
stepIndex, statusText and six steps { label, delay }. Each step sets
statusText to "[n/6] <label>..." and schedules the next one after
(text.length - 1) * (1000 / 50) + 300 ms of scramble plus the step's
delay. Pause clears the timer, Resume schedules the next step again. The
last step sets statusText to "Booked · ref RT-4821" and state to 'done'.
- Status line: useScrambleText(statusLine, {}, () => ({ text:
statusText.value, chars: '0123456789ABCDEF', settleDuration: 300,
revealRate: 50 })).
- Spinner: useWaapiAnimate(spinner, { rotate: { to: 360 }, duration: 800,
ease: 'linear', loop: true }). Watch paused and call spin.pause() or
spin.play().
- Log: the last three finished steps in <AnimeTransitionGroup tag="ul">
with enter { opacity: [0, 1], y: [8, 0], duration: 250, ease: 'out(3)' },
leave { opacity: 0, duration: 150, ease: 'out(2)' } and move { duration:
250, ease: 'out(3)' }.
- The spinner, a final check and an idle caret swap inside <AnimeTransition
mode="out-in"> with a small pop on enter.
- Buttons: "Book trip" when idle, Pause/Resume and Cancel while running,
"Book again" when done.
Reset scrolls the panel back to 0, resets the booking and replays the
title.