Design Lounge
Install

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

Open full size
1280 × 800 · 17.7 KB · 301 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 "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.

Brief

246 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 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                             │
│ /|\                                     /|\    ← 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) and svg.scene[role=img][aria-label].
  • In the SVG: g.sun, a cloud group, three hill pairs (fill + hatch), g#tufts, tent group with g#flags, g.guide, g.p-a and g.p-b (the players; each has a g.arm holding a use.held disc), two path.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):

ThingValue
Flight pathM452 574C540 470 660 420 780 430C880 438 940 470 1010 456C1080 444 1120 500 1148 574
Adahead r 8 at 424,530; shoulder 432,556; hand and held disc at 452,574
Junthe same drawing mirrored with translate(1600 0) scale(-1 1)
Discellipse rx 9 ry 3 pink over a rim ellipse 1.2 lower; inner ring rx 5.2 at 60%
Trailsame path, pathLength="100", stroke-dasharray: var(--len) 300
Hillsfar baseline 470 amp 140; mid baseline 540 amp 80; near baseline 612 amp 16 (quadratic bumps)
Hatches5px at −62° (45%), 7px at −28° (45%), 9px at 18° (20%), sun 6px at 35% (30%)
Narrow viewbelow 760px: viewBox 390 380 820 260, xMidYMax meet, overflow: visible

Typography

RoleFamilySizeWeightLine-heightLetter-spacingCase
HeadlineBricolage Grotesqueclamp(36px, 5.4vw, 68px)7000.98−0.035emSentence
Headline accentsameinherit700——pink
ParagraphBricolage Grotesque16px5001.50Sentence
WordmarkBricolage Grotesque18px7001−0.01emTitle
Header metaSpline Sans Mono12px50010.02emUPPER
Buttons, switchBricolage Grotesque14px700 / 50010Sentence
Speed chipsSpline Sans Mono13px50010—
Readout label / valueSpline Sans Mono12px / 13px5001.30Sentence
Guide labels (SVG)Spline Sans Mono15 units400——lower

Motion

All throw parts share --t (14s) and loop forever. Percentages are of one cycle.

ElementPropertyKeyframesEasingDelayReduced motion
Ada armrotate around shoulder0–6% 0° · 12% 115° · 14% −60° · 22–84% 0° · 90% −80° · 93% −70° · 100% 0°ease-in-out; --wind into 14%; ease-out after0still
Jun armsamesamesamecalc(var(--t) / -2)still
Held disc (each)opacity1 until 13.9% · 0 from 14% to 89.9% · 1 from 90%steps by keyframeJun −t/2hidden
Flying discoffset-distance, opacityhidden to 13.9% · 14% 0% → 40% 100% · hidden 40.1–63.9% · 64% 100% → 90% 0% · hidden after--flight on each leg0fixed at 55%, visible
Disc tiltrotate0–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-out0still
GlinttranslateX, opacity−4px, 0 → 4px, 0 (0.9 at 50%)linear0.35s loopstill
Trail (soft, core)stroke-dashoffset, opacity14% len → 40% len−100 · fade by 42% · 64% −100 → 90% 0 · fade by 92%--flight0static streak ending at 55%
SuntranslateY, opacity60px, 0 → 0, 1--ease1.6s oncestill
Cloudstranslate−300px → 1900pxlinear drift110s / 150s (−70s)still
Tufts (every 3rd)skewX from bottom−3° → 3°ease-in-out5.5s alternatestill
FlagsskewX, scaleY from top−8° → 8°, 1 → 0.9ease-in-out1.4s alternate, −0.17s eachstill
Flight guideopacity0 → 1--ease300ms on toggleinstant
Switch knobtranslateX0 → 16px--ease250msinstant

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-label switches 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 h1 and 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 radiogroup with roving tabindex and Left/Right arrow keys. The path switch is a toggle button with aria-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 260 with meet and overflow: 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-path with offset-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

  1. 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% then 40.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; }
}
  1. The comet trail is a dash sliding along the same path. With pathLength="100" and stroke-dasharray: len 300, a dash offset of len hides it before the start, len − 100 parks it at the end, −100 hides it past the end, and 0 parks 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; }
}
  1. 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 with transform-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.

  2. 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;
  1. 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
Designed bySusan AcharyaKathmandu

Studied

Susan Acharya

Draw one scene and let it move a little. The words stay calm on top.

All sources