Design Lounge
Install

Nº 477 of 520

Winding ribbon scroll story

A thick orange road ribbon winds down the page between alternating step cards, drawing itself as you scroll while a small van drives along it.

Preview

Open full size
1280 × 800 · 14.3 KB · 260 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Winding ribbon scroll story" (piece winding-ribbon-scroll-story) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

251 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

Studied from mindmarket.com: the big green ribbon that swoops down behind the “No more chaos / One brief / Global, for real” cards, tying the page into one path with flat illustrated figures riding along it. This version is a “how a move works” section for a fictional international moving company, Overlook. A 96px orange road with a dashed centre line winds from the hero down through four numbered stops (Survey, Pack, Cross, Unpack). Each stop has a white card on the opposite side and a small flat illustration (boxes, a plant, a suitcase, a sofa) next to the road. The road draws itself as you scroll; ahead of it a faint dotted line shows the route still to come; a little cobalt van rides the drawn tip, turning with the curve. The detail worth copying: the path is built in JavaScript from the real positions of the cards, so the road always passes beside its card at any width, and scroll position is mapped to path length through the path’s own y-coordinates, so the van stays level with the reader’s eye line.

Reference behaviour

  1. First frame (scrollY 0): a 72px nav, a centred hero (“One crew. / One long road.” at 104px with a handwritten “door to door, 38 countries” tilted −3° above), then the start of the road: about 120px of orange ribbon under the hero with the van on it, a dotted guide running on to a hollow pin “1”, the Day 1 Survey card on the left, and a stack of kraft boxes to the right of the pin.
  2. The route: start at the story’s horizontal centre, 40px from its top. Stop anchors are at the vertical centre of each 560px row, at 70% of the width when the card is on the left and 30% when it is on the right. The end anchor is at the centre, 16px into the closing block.
  3. Between anchors, each segment is a cubic Bézier with vertical control handles of 55% of the segment’s height. This makes y strictly increase along the path, which the scroll mapping depends on.
  4. On scroll, the eye line is scrollY + 0.72 × innerHeight. The drawn length is the path length whose y equals the eye line, found by binary search over samples taken every 6px of length. Minimum drawn length is 28px.
  5. The drawn road is revealed by a mask: a white stroke 8px wider than the road, with stroke-dasharray: <drawn> <total + 200>. The road and the centre dashes both sit inside the masked group.
  6. The undrawn route is a 3px dotted line (stroke-dasharray: 1 12, round caps) in ink at 28% opacity, always visible.
  7. The van sits at the drawn tip, rotated to the tangent (angle from the point at len to len + 2). When the angle passes ±90°, it is mirrored vertically so it never drives upside down.
  8. A stop’s pin turns from white to sun yellow when the van’s y reaches the pin. A polite live region says “Stop 2 of 4: Pack”.
  9. A card fades and rises into place when the eye line is within 260px above its anchor: opacity 0 → 1, translateY 32px → 0 while keeping its resting tilt (−1.2° left, 1° right). The illustration pops from scale 0.6 when the eye line is within 200px.
  10. Pins are buttons. Clicking pin n scrolls so the van lands just past that pin.
  11. Scrolling back up undraws the road and hides cards again. Everything is reversible.
  12. The road ends in a round cap above “keys in hand / Home, again.” and an orange Get a moving quote pill.

Structure

1280 wide, page scrolls (≈ 3400px tall)
┌────────────────────────────────────────────────────────────┐
│ nav 72px  ⟋ Overlook                    [Get a moving quote]│
├────────────────────────────────────────────────────────────┤
│ hero 440px, centred                                        │
│        door to door, 38 countries  (Caveat 28, −3°)        │
│             One crew.                                       │
│          One long road.    (104px / 800)                   │
│      International home moves run by … (17px, 440 wide)    │
├────────────────────────────── story (position: relative) ──┤
│                         ██ road start, x 50%, y 40          │
│  ┌ card 420 ─────┐          ██                              │
│  │ Day 1         │            ██ (1) pin at 70%   [boxes]   │ row 560
│  │ Survey        │          ██                              │
│  └───────────────┘      ██                                  │
│        [plant]   (2) ██                ┌ card 420 ──────┐   │ row 560
│                       ██               │ Day 9  Pack    │   │
│   … rows 3 and 4 alternate …                               │
│                         ██ end cap                          │
│                keys in hand / Home, again. / [quote]        │
└────────────────────────────────────────────────────────────┘
SVG overlay: absolute, inset 0, viewBox = story's pixel box.
  • header.nav: fixed, brand link and a dark pill CTA.
  • section.hero with the only h1.
  • section.story labelled “How a move works”: an svg.route (aria-hidden) holding the mask, the dotted guide, the masked road group and the van group; four div.row each holding an article.card with h2; four svg.deco illustrations; pins injected as button.pin; and a closing div.end.
  • Card: p.day (handwritten), h2, p, div.meta with two facts.

Tokens

:root {
  /* colour */
  --bg: #e4efe6;        /* mint paper */
  --card: #fffdf8;      /* cards, pin fill */
  --ink: #1d2b26;       /* text, pin ring, guide line */
  --ink-2: #4a5a53;     /* body copy */
  --line: #c6d6ca;      /* card border, rules, nav rule */
  --road: #f05a28;      /* ribbon, handwritten notes, end CTA */
  --road-mark: #fff3e6; /* centre dashes */
  --cobalt: #2f4bd8;    /* van, focus ring, nav CTA hover */
  --sun: #ffc93c;       /* reached pin, sofa */
  --kraft: #d9a066;     /* boxes */
  --leaf: #3e9b5f;      /* plant */

  /* type */
  --display: "Bricolage Grotesque", system-ui, sans-serif;
  --hand: "Caveat", cursive;

  /* shape */
  --road-w: 96px;
  --r-card: 24px;
  --row-h: 560px;
  --card-w: 420px;
  --pin: 48px;

  /* motion */
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --expo: cubic-bezier(0.16, 1, 0.3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTracking
Hero h1Bricolage Grotesque, opsz 96104px8000.92−0.045em
Closing h2Bricolage Grotesque88px8000.95−0.045em
Card h2Bricolage Grotesque44px8001−0.035em
BodyBricolage Grotesque17px4001.50
Card metaBricolage Grotesque14px6001.50
End CTA labelBricolage Grotesque19px80010
Nav CTA labelBricolage Grotesque15px60010
Pin numberBricolage Grotesque18px80010
BrandBricolage Grotesque22px8001−0.03em
Handwritten notesCaveat26–28px60010, in --road

Handwriting is only for the small day labels and the two hero/closing notes. Never set a heading in Caveat.

Motion

ThingTriggerPropertyFrom → ToTimingReduced motion
Road drawscrollmask stroke-dasharray28 → totalscroll-linkedsame (user-driven)
Vanscrolltranslate + rotate along pathtip of roadscroll-linkedsame
Card revealeye line within 260px of anchoropacity, translateY0, 32px → 1, 0500ms / 600ms --expoappears instantly
Illustration popeye line within 200pxscale, translateY, opacity0.6, 16px, 0 → 1, 0, 1700ms --expoinstant
Pin reachedvan passes pinbackground--card → --sun240ms --easeinstant
Pin hoverhoverscale1 → 1.08240ms --exponone
Pin clickclickwindow scroll—smoothbehavior: auto
End CTA hoverhovertranslateY0 → −2px180ms --exponone

Run all scroll work inside one requestAnimationFrame per burst. Never put a CSS transition on the van or the mask; it must track scroll exactly.

States

  • Pin upcoming: white fill, 3px ink ring, ink number.
  • Pin reached: sun yellow fill. Only the van’s position decides this, not the click.
  • Pin hover: scale 1.08. Focus-visible: 3px cobalt ring, 3px offset.
  • Card hidden: opacity 0, 32px down. Card shown: opacity 1, resting tilt.
  • Nav CTA hover: ink → cobalt.
  • Road before scrolling: a 28px stub with the van, never an empty gap.
  • Loading: the path is rebuilt again after document.fonts.ready, because the card heights change when the fonts swap.

Accessibility

  • The SVG route is decorative (aria-hidden="true"). The story is fully readable as four articles in order.
  • The section has aria-label="How a move works". Each card’s heading is an h2 under the hero h1.
  • Pins are buttons with aria-label="Go to stop 3: Cross". They are in DOM order after the cards; tabbing reaches them in stop order.
  • A polite live region announces each newly reached stop.
  • Illustrations are aria-hidden.
  • Contrast: #4a5a53 on #fffdf8 is about 7:1; #1d2b26 on #f05a28 (end CTA) is about 4.3:1, so that label is set at 19px/800 to count as large text; white text is never placed on the orange.
  • Pin hit target is 48×48.

Responsive rules

  • ≥1280: as specified. Rows 560px, padding 8% each side, cards 420px.
  • 1024: same layout; anchors stay at 30% / 70% of the story width, so the road tightens its swings by itself.
  • <900: the road moves to a 36px lane down the left edge. Anchors alternate between x 34 and x 46 so it still wiggles slightly. Rows become auto height with padding 48px 16px 48px 76px; cards fill the width; card h2 34px; illustrations hidden; the van scales to 0.6; hero h1 54px; closing h2 48px.
  • <640: same as <900. Confirm no horizontal scroll at 375px.
  • Rebuild the path on every resize. Never hard-code the d attribute.

Acceptance checklist

Always

  • The path’s d is generated from the measured row positions and regenerated on resize and after fonts load.
  • Every segment uses vertical control handles, so y increases monotonically along the path.
  • Scroll maps to length through the path’s own y values, not a linear scroll fraction.
  • The drawn portion is revealed by a mask, and the dashed centre line is inside the same masked group.
  • A faint dotted guide shows the route ahead of the drawn road.
  • The rider follows the tangent and is mirrored past ±90° so it never turns upside down.
  • Each stop has a button pin that scrolls to it and fills when reached.
  • Cards alternate sides and the road passes on the opposite side of each card.
  • Scrolling up reverses everything.
  • No horizontal scroll at 375px.

This demo

  • Road 96px #f05a28, centre dashes 4px #fff3e6 at 18/22.
  • Hero “One crew. / One long road.” at 104px Bricolage 800.
  • Stops: Day 1 Survey, Day 9 Pack, Day 12 Cross, Day 31 Unpack.
  • Eye line at 72% of the viewport height.
  • Van is cobalt #2f4bd8, 68×42.

Implementation notes

  1. Build the path from anchors. Vertical handles make each segment an S-curve whose y never goes backwards:
let d = `M${a[0].x} ${a[0].y}`;
for (let i = 1; i < a.length; i++) {
  const p = a[i - 1], q = a[i], k = (q.y - p.y) * 0.55;
  d += ` C${p.x} ${p.y + k} ${q.x} ${q.y - k} ${q.x} ${q.y}`;
}
[guide, road, mark, maskPath].forEach(el => el.setAttribute('d', d));
total = road.getTotalLength();
samples = [];
for (let l = 0; l <= total; l += 6) samples.push([l, road.getPointAtLength(l).y]);
  1. Map the eye line to a length with a binary search, then interpolate:
function lengthAtY(y) {
  if (y <= 0) return 0;
  let lo = 0, hi = samples.length - 1;
  if (y >= samples[hi][1]) return total;
  while (hi - lo > 1) { const m = (lo + hi) >> 1; samples[m][1] < y ? lo = m : hi = m; }
  const [l0, y0] = samples[lo], [l1, y1] = samples[hi];
  return l0 + (l1 - l0) * ((y - y0) / ((y1 - y0) || 1));
}
  1. Draw with a mask, not by dashing the road itself. Dashing the road would fight the centre line’s own dasharray:
<defs><mask id="drawn" maskUnits="userSpaceOnUse"><path class="mask-path" id="maskPath"/></mask></defs>
<path class="guide"/>
<g mask="url(#drawn)"><path class="road"/><path class="mark"/></g>
.mask-path { fill: none; stroke: #fff; stroke-width: calc(var(--road-w) + 8px); stroke-linecap: round; }
maskPath.style.strokeDasharray = `${len} ${total + 200}`;

Common mistakes:

  • Mapping scroll fraction straight to path length. On the long horizontal swings the van then races ahead of the reader and lags on the straights.
  • Drawing the road with a fixed viewBox and preserveAspectRatio. At other widths it no longer passes beside the cards and the stroke width scales.
  • Letting the van flip upside down on leftward segments.
  • Putting the road above the cards. The SVG is under the cards (z-index 1 vs 2) and pins sit above both.
  • Measuring before the fonts load. Card heights change, the anchors drift, and the road cuts through a card.
  • Using a CSS transition on the van to “smooth” it. It lags behind the road tip.

Where it sits:

  1. It is the “how it works” or “why us” middle of a landing page, between the hero and a CTA band. It is not a hero by itself.
  2. Three to six stops. Fewer and the road has nothing to wind between; more and the page becomes a scroll chore.
  3. The rider is a small flat object that fits the product (a van for moving, a paper plane for travel, a parcel for delivery). Keep it under 72px long.
  4. The illustrations are flat shapes in the page palette, no outlines, no gradients. One per stop, on the road’s open side.
  5. If the page already has a pinned section, do not put this right after it. Two scroll-driven sections back to back feel like the page is fighting the reader.

Rebuild order:

  1. Lay out the hero, four rows and the closing block in normal flow, with cards alternating sides.
  2. Add the absolute SVG overlay and size its viewBox to the story box.
  3. Compute anchors from the rows and draw the full path as the dotted guide.
  4. Add the road and centre line inside a masked group; drive the mask from a fixed length to check it.
  5. Add the y-sampled length lookup and wire it to scroll.
  6. Add the van with tangent rotation and the vertical mirror.
  7. Inject the pins, then the card and illustration reveals.
  8. Rebuild on resize and after fonts load. Check 375px.

Details

Palette
Type
Bricolage Grotesque · Caveat
Motion
Rich motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

MindMarket

One path ties the page together. Cards sit beside it, and the numbers pile up as you scroll.

All sources