Skip to main content

Video & line drawing

Four recipes that reveal content progressively as something is "drawn": a full-screen video that scrubs forwards and backwards, an SVG illustration that draws itself stroke by stroke, a vertical timeline whose line fills to match the milestone in view, and a traveller that moves along a winding road.

Video scrub​

A full-screen video follows the scroll frame by frame while the stage is pinned — scrolling down plays it, scrolling back up rewinds it — with captions taking turns on top. See Video scrub for the setting itself; this is the applied, full-scene version of it.

Structure (a 400vh pinned scene, pinned 20–80%):

div .pin__stage
├─ video .vscrub__video "Get parent" + Video scrub, Start 20, End 80 (+ optional Custom, same window)
├─ div .vscrub__shade legibility gradient, absolute
├─ div .vscrub__content captions — build with the "Sequence: items that take turns" recipe, Start 20, End 80
└─ div .scroll-progress optional — see "Scene height & progress indicators", Custom 20–80

CSS:

.vscrub__video { position: absolute; inset: 0; z-index: -1; pointer-events: none; }
.vscrub__video, .vscrub__video video { width: 100%; height: 100%; object-fit: cover; }
.vscrub__shade {
position: absolute; inset: 0; z-index: 0; pointer-events: none;
background: linear-gradient(90deg, rgb(0 0 0 / .8), transparent 60%);
}
.vscrub__content { position: relative; z-index: 1; color: #fff; }

Optional, for phones: shift the crop as the scroll advances, so a product that moves across the frame stays in view, using a Custom row on the same element: object-position: calc(54% + var(--fa-scroll--custom) * 16%) 50%; on the <video> itself.

The video file decides whether it's smooth — see Video scrub's encoding notes: MP4 (H.264), no audio track, constant frame rate, 1080p or less, a keyframe on every frame ("all-intra"), and roughly 300 frames or more for a 400vh scene. All-intra files run large (about 7MB for 360 frames) — keep the clip itself short.

Use the native Video element, source "Media": mute on, inline on, preload "auto", object-fit "cover", pointer events "none", "Get parent", and a Video scrub row Start 20, End 80. Leave "Controls" out entirely (don't just set it to off — leave the key unset). Target the actual <video> tag for anything that only makes sense on it (like object-position on phones), since Bricks wraps it in its own div.

Common problems:

  • The video steps or visibly lags. Too few keyframes or too few frames — re-encode all-intra with 300+ frames, and confirm with Check video (3.2.0+, in the builder canvas).
  • Captions are all visible at once without the animation running. See the sequence recipe's note on gating shared-cell layout on the animation's own variable, never a body class.

SVG line drawing​

A line illustration draws itself while the stage is pinned, one group of strokes per step, with the group currently being drawn shown in an accent colour and finished groups in a neutral one. Bricks only — it needs an inline SVG, which Etch doesn't currently support importing the same way.

The SVG file​

  • Every stroke must be a <path> with pathLength="1" — this lets the same CSS draw any stroke identically, whatever its real geometric length actually is.
  • One <g> per step, as direct children of the <svg>, in drawing order.
  • No stroke, fill or colour attributes in the file — CSS sets all of that. Keep the viewBox.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 360">
<g><path pathLength="1" d="M24 24 H456 V336 H24 Z"/></g>
<g><path pathLength="1" d="M40 116 H232 V256 H40 Z"/><path pathLength="1" d="M264 132 H440"/></g>
</svg>

Structure (a 400vh pinned scene, pinned 20–80%):

div .pin__stage
└─ div .draw__grid 2 columns (1 on phones)
├─ div .draw__steps optional — "Sequence children", Start 20, End 80
└─ div .draw__frame
└─ svg .draw__svg "Get parent" + Custom, Start 20, End 80

CSS:

.draw__svg {
--dw-n: 4;
--dw-q: var(--fa-scroll--custom, 1);
width: 100%; height: auto; aspect-ratio: 4 / 3;
fill: none; stroke-width: 2px; stroke-linecap: round; stroke-linejoin: round;
}
.draw__svg:has(> g:nth-child(2)) { --dw-n: 2; }
.draw__svg:has(> g:nth-child(3)) { --dw-n: 3; }
/* … one rule per count, up to 12 */
.draw__svg > g {
--dw-i: 0;
--dw-t: calc(var(--dw-q) * var(--dw-n) - var(--dw-i));
--dw-v: clamp(0, var(--dw-t) / .7, 1);
--dw-act: clamp(0, min(var(--dw-t), 1 - var(--dw-t)) * 8, 1);
stroke: color-mix(in srgb, var(--primary, #2563eb) calc(var(--dw-act) * 100%), #3f3f46);
opacity: min(var(--dw-v) * 20, 1);
}
.draw__svg > g:nth-child(2) { --dw-i: 1; }
.draw__svg > g:nth-child(3) { --dw-i: 2; }
/* … up to 12 (--dw-i: 11) */
.draw__svg > g > * {
vector-effect: non-scaling-stroke;
stroke-dasharray: 1;
stroke-dashoffset: calc(1 - var(--dw-v));
}

Group i (from 0) of n draws while q·n runs from i to i + .7 — the first 70% of its share, which lines up with step i's own window if you're also using a sequence beside it with the same Start/End. opacity hides a group until it starts (round caps would otherwise paint a zero-length dash as a visible dot). Without the animation, --dw-q falls back to 1 and the whole drawing shows at once.

Values you can change: .7 (the share of each step actually spent drawing — the rest is a pause before the next one starts), * 8 (how quickly the active colour fades in and out), stroke width and colours.

  • SVG element with source File (not Code): Bricks reads the file and prints the <svg> inline, so the CSS can reach every stroke, and its sanitiser keeps the pathLength attribute. Uploading SVG files needs the "Upload SVG" permission turned on under Bricks → Settings → Builder access — ask the site owner to enable it if it isn't already.
  • SVG element: "Get parent" + one Custom row, Start 20, End 80.

Common problems:

  • A stroke shows as fine dashes instead of actually drawing. That particular path is missing pathLength="1", so the one-unit dash is being measured against its real length instead.
  • Lines get thicker or thinner as the illustration scales. Missing vector-effect: non-scaling-stroke.
  • Dots appear before a group is meant to start. Round line caps on a zero-length dash — keep the opacity rule that hides ungrouped strokes.
  • Drawing and a sequence of steps beside it drift apart. Different Start/End values on the SVG row and the sequence — use the exact same range for both.

Timeline: a line that fills, milestones that light up​

A list of milestones scrolls completely normally. A vertical line beside them fills down exactly to the middle of the screen; each milestone's dot lights up the instant the line reaches it, and its card slides in. An optional giant year counts in the background, from the first milestone's year to the last.

This is the recommended choice whenever a winding, hand-drawn line (the journey map below) isn't possible — for instance, when SVG uploads aren't available on the site. Don't try to fake a curve with repeating gradients or patterns; it reads as broken, not hand-drawn.

Structure:

section .tl side padding, overflow-x reset
└─ div .tl__list Custom, no Start/End; scroll smoothing ~0.1s
├─ div .tl__year optional — sticky giant year, hidden from screen readers
├─ div .tl__line the track, full height of the list
│ └─ div .tl__fill the filled part
└─ div .tl__item ×n Custom, no Start/End each; 70vh tall
├─ div .tl__dot
└─ div .tl__card > span .tl__date, h3, p

The list and each item measure themselves (no "I am parent"/"Get parent" needed here). The line and year read the list's --fa-scroll--custom; each dot and card read its own item's.

CSS:

@property --tl-year { syntax: '<integer>'; inherits: true; initial-value: 2015; }

.tl { width: 100%; padding: 6rem clamp(1rem, 5vw, 2rem); overflow-x: initial; }
.tl__list { position: relative; width: 100%; max-width: 56rem; margin: 0 auto; overflow-x: clip; --tl-x: 1.25rem; --tl-n: 6; }

/* the line: 100% of its own height IS the list's height, so the fill needs no division */
.tl__line { position: absolute; left: var(--tl-x); top: 0; bottom: 0; width: 2px; background: #2a3442; }
.tl__fill { width: 100%; background: #00b5c0; height: clamp(0%, var(--fa-scroll--custom) * (100% + 100vh) - 50vh, 100%); }

.tl__item { position: relative; z-index: 1; min-height: 70vh; display: flex; align-items: center; padding-left: calc(var(--tl-x) * 2 + 2rem); }
/* an item is centred on the screen exactly when its own progress is 0.5 */
.tl__dot {
position: absolute; left: calc(var(--tl-x) - .55rem + 1px); top: 50%; translate: 0 -50%;
width: 1.1rem; height: 1.1rem; border-radius: 50%;
--on: clamp(0, (var(--fa-scroll--custom) - .45) / .05, 1);
background: color-mix(in srgb, #00b5c0 calc(var(--on, 1) * 100%), #2a3442);
box-shadow: 0 0 0 calc(var(--on, 1) * 6px) rgb(0 181 192 / .2);
}
.tl__card {
width: 100%; border-radius: 1rem; padding: clamp(1.25rem, 3vw, 2rem); background: rgb(26 33 43 / .8);
--in: clamp(0, (var(--fa-scroll--custom) - .15) / .2, 1);
opacity: var(--in, 1); translate: calc((1 - var(--in, 1)) * 2rem) 0;
}

/* optional: the year of the centred milestone, counting between them (evenly spaced in time) */
.tl__year {
position: sticky; top: 50vh; height: 0; pointer-events: none; text-align: right;
--tl-s: calc((var(--fa-scroll--custom) * (var(--tl-n) * .7 + 1) - .5) / .7 - .5);
--tl-year: calc(2015 + clamp(0, var(--tl-s) / (var(--tl-n) - 1), 1) * 10);
counter-reset: tly var(--tl-year);
}
.tl__year::before { content: counter(tly); display: block; translate: 0 -50%; font-weight: 800; font-size: clamp(5rem, 18vw, 16rem); line-height: 1; color: rgb(255 255 255 / .08); }

The fill percentage is derived from where the screen's middle currently sits relative to the top of the list; because the line's own 100% already equals the list's height, the math stays in lengths only, no division. Dots light up between progress 0.45 and 0.5 of their own item — right as the fill reaches them. The giant year relies on every item being the same height (70vh); set --tl-n, the first year and the span to match your real content. Without JavaScript, the fill is empty, every dot is lit, every card is visible, and the year shows its initial value.

Values you can change: --tl-x (line position), item height (keep the .7 in --tl-s consistent with it if you change it), colours, the first year/span in --tl-year, and the card's entrance timing (.15/.2). For unevenly-spaced dates, drop the counter and just show each card's own year directly instead.

  • List: one Custom row, scroll smoothing ~0.1s. Each item: its own Custom row (unique ids). Line, fill, dot, card, year have no Fancy settings of their own.
  • The year is pure decoration — give it aria-hidden="true".
  • Headings inside dark cards need their own colour set explicitly (they otherwise take the theme's heading colour).

Common problems:

  • The page scrolls sideways on phones. Cards waiting to slide in sit slightly off to the right, past the edge of the screen — use overflow-x: clip (not hidden, which would also stop the sticky year) on the list.
  • The big year never actually shows. Opaque cards are covering it, since it sits behind the list — use translucent card backgrounds, or place the year somewhere cards don't reach.
  • The fill runs ahead of, or behind, the dots. The line isn't the full height of the list, or the list has padding that the items themselves don't share.
  • The year is off by one milestone. Items aren't all exactly 70vh, or something else (a stray heading) sits directly inside the list.

Journey map​

A fixed-size "world" holding a hand-drawn winding road, drawn in SVG. While the stage is pinned, a dot travels along the road, the line fills in behind it, and stop cards — spread evenly along the road automatically — light up as the traveller reaches them. The world scales to the screen height and pans to keep the traveller centred. Bricks only (needs inline SVG uploads).

This is a substantially more involved build than the plain timeline above — reach for it specifically when a winding, illustrated route is part of the brief and SVG uploads are available on the site; otherwise the timeline recipe gives a very similar storytelling effect with far less setup.

Structure:

section .route-section overflow-x reset, padding 0
└─ div .route__scene own Custom row (no Start/End), Disable from tablet, scroll smoothing ~0.2s
└─ div .route__stage sticky
└─ div .route__world
├─ svg .route__road an SVG file with two <path> sharing the SAME d — the 2nd with pathLength="1"
├─ div .route__traveler
└─ ol .route__stops "Index children"
└─ li .route__stop ×n the stop cards (up to 12)

The road's shape exists in three places that must all agree: the two SVG <path>s, and a CSS path() used to move the traveller and stops along the same curve with offset-path. On tablet and phone it collapses back to a plain list — the sticky-world CSS and SVG are hidden via body.fanim--initialized, since offset-path can't be switched off with a scroll variable alone.

Values you can change: the done/todo colours, card width, "stem" length connecting a card to the road, how dim an unlit stop looks, screens of scroll per stop, how many stops before the world starts stretching horizontally, and the smallest card scale on short screens. A different road shape means updating d in both SVG paths and the CSS path() together — the camera and panning formulas assume the road fits within a 2400×900 box, running from roughly x=150 to x=2250.

  • Scene: "Disable from tablet", scroll smoothing ~0.2s, one Custom row. Stops (ol): "Index children". Nothing else needs Fancy settings.
  • SVG element, source File (inlined, so the CSS can reach the paths) — never source Code, which needs a per-site code signature that an imported/AI-written file can't carry. Uploading SVGs needs Bricks → Settings → Builder access → "Upload SVG" turned on.
  • Tablet-breakpoint rules go in each class's own tablet custom CSS field.

Common problems:

  • No inline SVG available (uploads turned off, or no suitable file). Use the timeline recipe instead — never fake a winding road with repeating gradients or patterns, it reads as broken rather than hand-drawn.
  • The traveller and stops drift off the line. The SVG was shrunk by a theme's max-width: 100% rule while the CSS path() coordinates stay in fixed pixels — set max-width: none on both the world and the SVG.
  • Cards drift further from the road the further along they are. They were shrunk with the scale property, which applies outside offset-path — use transform: scale() instead.
  • Browser support: tan()/atan2(), offset-path and color-mix() need a fairly recent browser — Chrome 111, Safari 16.2 or Firefox 113 at minimum.