Nº 237 of 520
Engraved disc throw loop
Two stick figures on hatched hills trade a pink flying disc along a curved motion path with a fading comet trail, all synced to one 14s CSS clock.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Engraved disc throw loop" (piece throw-loop-engraved-strip) 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.
<!-- Design Lounge Nº 237 · "Engraved disc throw loop" · www.designlounge.live -->
# Engraved disc throw loop
> **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 acharyasusan.com.np: the small game of catch in her hero valley, where two tiny figures throw a glowing disc back and forth along a curved path and the disc leaves a faint streak. This rebuild makes that the main event, in daylight, for **Fenmoor Disc Club**, a fictional Sunday pickup group. It is a living illustration you can drop behind a hero or a section. Everything (arm wind-up, release, the disc's flight, its wobble, the trail, the catch) runs on a single CSS custom property `--t: 14s`, and the second player is the same animation shifted by half a cycle. A bottom bar adds play/pause, speed and a "show flight path" switch, and reads out the current phase and a throw count. The details worth copying are `offset-path` for the flight and the half-cycle delay trick, which keeps two players in step without any JS timing.
## Reference behaviour
1. First frame (1280×800): a 68px header ("Fenmoor Disc Club" with a pink disc mark at left, "SUN 10:00 · FENMOOR COMMON · FREE" in mono at right). Below it the stage fills the rest, with a 72px control bar at the bottom.
2. Headline at the top left of the stage: "Throw it long." / "Catch it *late.*" ("late." in pink), with a 16px line under it.
3. Scene (SVG, `viewBox 160 180 1280 520`, slice): a hatched sun with an ink outline (r 78), two outline clouds drifting, a far hill band (solid sage + hatch), a mid band (pale sage + hatch), a cream near field with a light hatch and 44 grass tufts, a small tent with a rope of nine bunting flags, and two players about 750 units apart.
4. Ada (left, facing right) holds the disc. At 6–12% of the cycle her arm winds back to 115°; at 14% it snaps forward to −60° and the disc leaves her hand.
5. From 14% to 40% the disc flies along an S-shaped path (rises, dips, rises, drops into Jun's hand), tilting through a scripted wobble, with a white glint flicking across it every 0.35s. A pink comet trail follows: a soft 5px stroke (16 units long) and a 1.6px core (7 units long).
6. At 40% Jun (right, mirrored) reaches −80° and catches. The flying disc hides; the disc in Jun's hand shows.
7. Jun's cycle is the same, shifted by half: he winds up at 56–62% and throws at 64%. The disc flies the same path backwards to Ada (64% → 90%), and Ada catches at 90%.
8. Ambient: the sun rises 60px on load (1.6s), clouds drift (110s and 150s), every third tuft sways, the bunting flutters with a 0.17s offset per flag.
9. Control bar:
- Pause/Play (44px square button) pauses and resumes every animation in place.
- Speed (radio group 0.5× / 1× / 2×) changes `playbackRate` on every animation, so nothing restarts or drifts out of sync.
- "Show flight path" (switch) fades in a dashed guide of the path with a filled dot at the release point, a ring at the catch point, and mono labels "release 14%" and "catch 40%".
- Readout at right: "Now" with the current phase (Ada holds, Ada winds up, Disc in flight, Jun has it, Jun winds up, Disc coming back, Ada catches) and "Throws" counting each release.
10. Reduced motion: no animation. The disc is shown frozen at 55% of the path with its trail behind it, both held discs hidden, the play button disabled, and the phase reads "Held mid-flight".
## Structure
```
1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ ◒ Fenmoor Disc Club SUN 10:00 · FENMOOR COMMON · FREE │ 68px
├──────────────────────────────────────────────────────────────────────┤
│ Throw it long. ☁ ╭────╮ │
│ Catch it late. │ sun│ hatched │
│ Sunday pickup for every level… ╰────╯ │
│ ⌒⌒⌒⌒ far hills (sage, hatched) ⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒ │
│ ╭── ─ ─ ─ ─ ─flight path─ ─ ─ ─╮ │
│ o ◐~~ ▲ tent + flags ╲ o │
… 206 more lines. Copy or download for the full brief. Loading the source…Brief
246 linesBuild 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 acharyasusan.com.np: the small game of catch in her hero valley, where two tiny figures throw a glowing disc back and forth along a curved path and the disc leaves a faint streak. This rebuild makes that the main event, in daylight, for Fenmoor Disc Club, a fictional Sunday pickup group. It is a living illustration you can drop behind a hero or a section. Everything (arm wind-up, release, the disc’s flight, its wobble, the trail, the catch) runs on a single CSS custom property --t: 14s, and the second player is the same animation shifted by half a cycle. A bottom bar adds play/pause, speed and a “show flight path” switch, and reads out the current phase and a throw count. The details worth copying are offset-path for the flight and the half-cycle delay trick, which keeps two players in step without any JS timing.
Reference behaviour
- First frame (1280×800): a 68px header (“Fenmoor Disc Club” with a pink disc mark at left, “SUN 10:00 · FENMOOR COMMON · FREE” in mono at right). Below it the stage fills the rest, with a 72px control bar at the bottom.
- Headline at the top left of the stage: “Throw it long.” / “Catch it late.” (“late.” in pink), with a 16px line under it.
- Scene (SVG,
viewBox 160 180 1280 520, slice): a hatched sun with an ink outline (r 78), two outline clouds drifting, a far hill band (solid sage + hatch), a mid band (pale sage + hatch), a cream near field with a light hatch and 44 grass tufts, a small tent with a rope of nine bunting flags, and two players about 750 units apart. - Ada (left, facing right) holds the disc. At 6–12% of the cycle her arm winds back to 115°; at 14% it snaps forward to −60° and the disc leaves her hand.
- From 14% to 40% the disc flies along an S-shaped path (rises, dips, rises, drops into Jun’s hand), tilting through a scripted wobble, with a white glint flicking across it every 0.35s. A pink comet trail follows: a soft 5px stroke (16 units long) and a 1.6px core (7 units long).
- At 40% Jun (right, mirrored) reaches −80° and catches. The flying disc hides; the disc in Jun’s hand shows.
- Jun’s cycle is the same, shifted by half: he winds up at 56–62% and throws at 64%. The disc flies the same path backwards to Ada (64% → 90%), and Ada catches at 90%.
- Ambient: the sun rises 60px on load (1.6s), clouds drift (110s and 150s), every third tuft sways, the bunting flutters with a 0.17s offset per flag.
- Control bar:
- Pause/Play (44px square button) pauses and resumes every animation in place.
- Speed (radio group 0.5× / 1× / 2×) changes
playbackRateon every animation, so nothing restarts or drifts out of sync. - “Show flight path” (switch) fades in a dashed guide of the path with a filled dot at the release point, a ring at the catch point, and mono labels “release 14%” and “catch 40%”.
- Readout at right: “Now” with the current phase (Ada holds, Ada winds up, Disc in flight, Jun has it, Jun winds up, Disc coming back, Ada catches) and “Throws” counting each release.
- Reduced motion: no animation. The disc is shown frozen at 55% of the path with its trail behind it, both held discs hidden, the play button disabled, and the phase reads “Held mid-flight”.
Structure
1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ ◒ Fenmoor Disc Club SUN 10:00 · FENMOOR COMMON · FREE │ 68px
├──────────────────────────────────────────────────────────────────────┤
│ Throw it long. ☁ ╭────╮ │
│ Catch it late. │ sun│ hatched │
│ Sunday pickup for every level… ╰────╯ │
│ ⌒⌒⌒⌒ far hills (sage, hatched) ⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒⌒ │
│ ╭── ─ ─ ─ ─ ─flight path─ ─ ─ ─╮ │
│ o ◐~~ ▲ tent + flags ╲ o │
│ /|\ /|\ ← Ada left, Jun right │
│ / \ ,,, ,, near field + tufts ,, , / \ │
├──────────────────────────────────────────────────────────────────────┤
│ [❚❚] [0.5×|1×|2×] (○ Show flight path) Now: Disc in flight Throws 3│ 72px
└──────────────────────────────────────────────────────────────────────┘
header.top:span.logo(inline SVG disc + text),span.when.section.stage[aria-labelledby=h]:div.copy(h1, p) andsvg.scene[role=img][aria-label].- In the SVG:
g.sun, a cloud group, three hill pairs (fill + hatch),g#tufts, tent group withg#flags,g.guide,g.p-aandg.p-b(the players; each has ag.armholding ause.helddisc), twopath.trail,g.fly > g.tilt > use + path.shine. div.bar[role=group][aria-label="Throw controls"]:button#play,div.seg[role=radiogroup],button#path[aria-pressed],div.read.
Tokens
:root {
/* colour */
--sky: #dfe7e2; /* stage, lower sky */
--sky-2: #e9eee9; /* upper sky, cloud fill */
--ground: #f1ece0; /* near field, control bar */
--hill-far: #a7b7ad; /* far band */
--hill-mid: #cdd6cd; /* mid band */
--sun: #f6ead2;
--ink: #22312c; /* figures, hatch lines, text */
--ink-2: #4d5b55; /* paragraph, mono labels */
--ink-3: #7a8680;
--line: rgba(34,49,44,.16);
--accent: #d6336c; /* the disc, trail, flags, "late." */
--accent-dk: #a3204f; /* disc rim */
/* type */
--display: "Bricolage Grotesque", system-ui, sans-serif;
--mono: "Spline Sans Mono", ui-monospace, monospace;
/* motion */
--t: 14s; /* one full there-and-back */
--ease: cubic-bezier(.22, 1, .36, 1); /* UI and sun rise */
--flight: cubic-bezier(.3, .55, .55, 1); /* fast release, floating finish */
--wind: cubic-bezier(.6, 0, .9, .5); /* arm snap at release */
}
Scene numbers (SVG user units):
| Thing | Value |
|---|---|
| Flight path | M452 574C540 470 660 420 780 430C880 438 940 470 1010 456C1080 444 1120 500 1148 574 |
| Ada | head r 8 at 424,530; shoulder 432,556; hand and held disc at 452,574 |
| Jun | the same drawing mirrored with translate(1600 0) scale(-1 1) |
| Disc | ellipse rx 9 ry 3 pink over a rim ellipse 1.2 lower; inner ring rx 5.2 at 60% |
| Trail | same path, pathLength="100", stroke-dasharray: var(--len) 300 |
| Hills | far baseline 470 amp 140; mid baseline 540 amp 80; near baseline 612 amp 16 (quadratic bumps) |
| Hatches | 5px at −62° (45%), 7px at −28° (45%), 9px at 18° (20%), sun 6px at 35% (30%) |
| Narrow view | below 760px: viewBox 390 380 820 260, xMidYMax meet, overflow: visible |
Typography
| Role | Family | Size | Weight | Line-height | Letter-spacing | Case |
|---|---|---|---|---|---|---|
| Headline | Bricolage Grotesque | clamp(36px, 5.4vw, 68px) | 700 | 0.98 | −0.035em | Sentence |
| Headline accent | same | inherit | 700 | — | — | pink |
| Paragraph | Bricolage Grotesque | 16px | 500 | 1.5 | 0 | Sentence |
| Wordmark | Bricolage Grotesque | 18px | 700 | 1 | −0.01em | Title |
| Header meta | Spline Sans Mono | 12px | 500 | 1 | 0.02em | UPPER |
| Buttons, switch | Bricolage Grotesque | 14px | 700 / 500 | 1 | 0 | Sentence |
| Speed chips | Spline Sans Mono | 13px | 500 | 1 | 0 | — |
| Readout label / value | Spline Sans Mono | 12px / 13px | 500 | 1.3 | 0 | Sentence |
| Guide labels (SVG) | Spline Sans Mono | 15 units | 400 | — | — | lower |
Motion
All throw parts share --t (14s) and loop forever. Percentages are of one cycle.
| Element | Property | Keyframes | Easing | Delay | Reduced motion |
|---|---|---|---|---|---|
| Ada arm | rotate around shoulder | 0–6% 0° · 12% 115° · 14% −60° · 22–84% 0° · 90% −80° · 93% −70° · 100% 0° | ease-in-out; --wind into 14%; ease-out after | 0 | still |
| Jun arm | same | same | same | calc(var(--t) / -2) | still |
| Held disc (each) | opacity | 1 until 13.9% · 0 from 14% to 89.9% · 1 from 90% | steps by keyframe | Jun −t/2 | hidden |
| Flying disc | offset-distance, opacity | hidden to 13.9% · 14% 0% → 40% 100% · hidden 40.1–63.9% · 64% 100% → 90% 0% · hidden after | --flight on each leg | 0 | fixed at 55%, visible |
| Disc tilt | rotate | 0–14% −18° · 19% 6° · 24% −8° · 31% −24° · 36% 12° · 40–64% 0° · 69% 16° · 74% −20° · 81% 8° · 86% −6° · 90–100% −18° | ease-in-out | 0 | still |
| Glint | translateX, opacity | −4px, 0 → 4px, 0 (0.9 at 50%) | linear | 0.35s loop | still |
| Trail (soft, core) | stroke-dashoffset, opacity | 14% len → 40% len−100 · fade by 42% · 64% −100 → 90% 0 · fade by 92% | --flight | 0 | static streak ending at 55% |
| Sun | translateY, opacity | 60px, 0 → 0, 1 | --ease | 1.6s once | still |
| Clouds | translate | −300px → 1900px | linear drift | 110s / 150s (−70s) | still |
| Tufts (every 3rd) | skewX from bottom | −3° → 3° | ease-in-out | 5.5s alternate | still |
| Flags | skewX, scaleY from top | −8° → 8°, 1 → 0.9 | ease-in-out | 1.4s alternate, −0.17s each | still |
| Flight guide | opacity | 0 → 1 | --ease | 300ms on toggle | instant |
| Switch knob | translateX | 0 → 16px | --ease | 250ms | instant |
Controls act on the running animations through the Web Animations API (document.getAnimations()), not by changing --t, so speed changes never restart or desync the loop.
States
- Play/Pause button: 44px square, 12px radius, 1.5px ink border. Shows a pause icon while running and a play icon while paused;
aria-labelswitches between “Pause” and “Play”. Hover: ink fill, cream icon. Active: scale 0.96. Disabled at 40% opacity in reduced motion. - Speed chips: joined segmented control with 1.5px ink borders. Checked chip: ink fill, cream text. Hover on unchecked: 8% ink.
- Flight path switch: 38×22 track; off 20% ink, on pink with the knob 16px to the right;
aria-pressed. - Readout: phase text updates every frame from the disc animation’s progress; the throw count increases at 14% and 64%.
- Focus-visible: 2px pink outline, 3px offset.
Accessibility
- The scene is
role="img"with the label “Two players on a hillside trade a pink flying disc back and forth”. Its parts are not focusable. - The headline is a real
h1and labels the stage section. - Pause is always available and is the first control in the bar, so anyone bothered by motion can stop it in one step (WCAG 2.2.2).
- Speed is a
radiogroupwith roving tabindex and Left/Right arrow keys. The path switch is a toggle button witharia-pressed. - The readout is
aria-live="off"; it changes several times a second and must not be announced. - Contrast: ink on sky about 11:1; ink-2 paragraph on sky about 6:1; pink on sky about 4.6:1 (used for the large headline word, not body text).
- Targets: every control is at least 44px tall.
Responsive rules
- ≥ 1280: as described; the slice crop keeps both players and the tent in view.
- 1024: same viewBox; the crop trims the sky edges a little more.
- < 760: header meta hides. The SVG switches to
viewBox 390 380 820 260withmeetandoverflow: visible, so the whole throw fits the width and the hills and sun still paint above it. The readout moves to its own full-width row under the controls. - 375: headline 36px; both players visible near the bottom of the stage; controls wrap to two rows plus the readout. No horizontal scroll.
Acceptance checklist
Always
- One custom property (
--t) drives arms, held discs, flight, tilt and trail. - The second player uses the same keyframes with
animation-delay: calc(var(--t) / -2)and a mirrored drawing. - The disc follows an SVG-coordinate
offset-pathwithoffset-rotate: 0deg(it must not turn along the curve). - Release happens at 14% and 64%; catches at 40% and 90%; the held disc and flying disc swap at exactly those points.
- Trail uses
pathLength="100"and a dash of 16 (soft) and 7 (core) units that leads into the catch and fades within 2%. - Pause freezes everything in place and resumes from the same frame.
- Speed uses
playbackRate; the two players stay in step after any change. - The flight-path guide shows the exact path the disc follows.
- Reduced motion shows a complete still frame with the disc mid-flight.
- No horizontal scroll at 375px.
This demo
- Club name “Fenmoor Disc Club”; headline “Throw it long. / Catch it late.”
- Players are named Ada (left) and Jun (right) in the phase readout.
- Guide labels read “release 14%” and “catch 40%”.
- Nine bunting flags alternate pink and ink.
Implementation notes
- Flight with
offset-path, gated by opacity. One element flies both ways: forward from 14% to 40%, backward from 64% to 90%. Hide it between legs with near-duplicate keyframe stops (40%then40.1%) so it does not slide back visibly.
.fly {
opacity: 0;
offset-path: path("M452 574C540 470 660 420 780 430C880 438 940 470 1010 456C1080 444 1120 500 1148 574");
offset-rotate: 0deg;
animation: fly var(--t) infinite;
}
@keyframes fly {
0%, 13.9% { offset-distance: 0%; opacity: 0; }
14% { offset-distance: 0%; opacity: 1; animation-timing-function: cubic-bezier(.3,.55,.55,1); }
40% { offset-distance: 100%; opacity: 1; }
40.1%, 63.9%{ offset-distance: 100%; opacity: 0; }
64% { offset-distance: 100%; opacity: 1; animation-timing-function: cubic-bezier(.3,.55,.55,1); }
90% { offset-distance: 0%; opacity: 1; }
90.1%, 100% { offset-distance: 0%; opacity: 0; }
}
- The comet trail is a dash sliding along the same path. With
pathLength="100"andstroke-dasharray: len 300, a dash offset oflenhides it before the start,len − 100parks it at the end,−100hides it past the end, and0parks it at the start.
.trail { fill: none; stroke: var(--accent); stroke-linecap: round;
stroke-dasharray: var(--len) 300; opacity: 0; animation: trail var(--t) infinite; }
.trail-soft { --len: 16; stroke-width: 5; stroke-opacity: .16; }
.trail-core { --len: 7; stroke-width: 1.6; stroke-opacity: .55; }
@keyframes trail {
0%, 13.9% { stroke-dashoffset: var(--len); opacity: 0; }
14% { stroke-dashoffset: var(--len); opacity: 1; animation-timing-function: cubic-bezier(.3,.55,.55,1); }
40% { stroke-dashoffset: calc(var(--len) - 100); opacity: 1; }
42%, 63.9% { stroke-dashoffset: -100; opacity: 0; }
64% { stroke-dashoffset: -100; opacity: 1; animation-timing-function: cubic-bezier(.3,.55,.55,1); }
90% { stroke-dashoffset: 0; opacity: 1; }
92%, 100% { stroke-dashoffset: 0; opacity: 0; }
}
-
Mirror the drawing, not the keyframes. Put Jun’s body and arm inside
<g transform="translate(1600 0) scale(-1 1)">using Ada’s coordinates. Rotate the arm withtransform-box: fill-box; transform-origin: 0 0(the shoulder is the top-left of the arm’s box), and his wind-up turns the correct way for free. Writing a second, negated keyframe set is the usual mistake. -
Control every animation at once.
const anims = () => document.getAnimations().filter(a => a.animationName);
const setPaused = p => anims().forEach(a => p ? a.pause() : a.play());
const setRate = r => anims().forEach(a => { a.playbackRate = r; });
// phase: read progress from the flight animation
const fly = document.getAnimations().find(a => a.animationName === 'fly');
const p = (fly.currentTime % fly.effect.getTiming().duration) / fly.effect.getTiming().duration;
- A different crop for phones. A wide scene sliced into a tall phone stage loses both players. Swap the viewBox and switch to
meet, and let the SVG paint outside its viewBox so the sky and hills still fill the space above.
const fit = () => {
const narrow = innerWidth < 760;
scene.setAttribute('viewBox', narrow ? '390 380 820 260' : '160 180 1280 520');
scene.setAttribute('preserveAspectRatio', narrow ? 'xMidYMax meet' : 'xMidYMax slice');
};
fit(); addEventListener('resize', fit);
Common mistakes: letting offset-rotate default to auto (the disc turns on its side along the curve), changing --t for speed (every animation restarts from 0), and using transform-origin in pixels for the mirrored arm (the origin lands on the wrong side).
Details
- Palette
- Type
- Bricolage Grotesque · Spline Sans Mono
- Motion
- Rich motion
- Build
- One session
- Tags
Studied
Susan AcharyaDraw one scene and let it move a little. The words stay calm on top.
All sources