모션·인터랙션 / Cadence
Annotation Lines
이미지 · 텍스트 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Annotation Lines
Callouts on a photograph, one after another, building up. For each: a small ring lands
on a detail, a level leader line draws out from beside it to the margin (white on the
photo, black on the page), and its label (a small caps eyebrow over a short line) fades
in and slides into place. Each stays as the next arrives. Plays when the photo comes
into view, loops (all in, hold, fade out, again) and restarts on click.
Dependencies: none (plain HTML, CSS, SVG and JS).
Tweak (in the script):
- FIRST / STAGGER: first mark 0.4s after the photo is in view, then one every 0.75s
- RING_IN / RING_R / DOT_R: ring radius 3 → 5.5px over 0.5s (expo out), 2.1px dot
- LINE_IN: the leader draws out over 0.9s (expo out); RING_CLEAR: it starts 11px beside the anchor
- LABEL_IN / LABEL_SLIDE / LABEL_DX: label fades 0.55–1.3s and slides 16px in (0.55–1.25s) after its mark starts
- LEAD_GAP: 24px between a leader's end and its label (at a 780px-tall composition)
- HOLD / OUT: 2.5s hold with all marks in, 0.5s fade out before the loop
- Anchors: data-x / data-y on each label, as fractions of the photo (0–1)
- Layout: --ratio (photo width / height), --gap and --col (in units of the composition height)
From the "Design with Skills" explainer, animation 03 (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Annotation Lines</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; background: #fff; color: #000; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.al {
--gutter: clamp(16px, 2.5vw, 32px);
display: grid;
place-items: center;
min-height: 100svh;
padding: 48px var(--gutter);
}
/* The composition, in units of its height (--h): the photo on the left, a gap, the label column */
.al-comp {
--ratio: 0.75; /* the photo's width / height (3:4) */
--gap: 0.17;
--col: 0.45;
--h: min(calc(100svh - 96px), 780px, calc((100vw - 2 * var(--gutter)) / (var(--ratio) + var(--gap) + var(--col))));
--eyebrow: max(9px, calc(var(--h) * 0.0154)); /* 12px at 780 tall */
--text: max(12px, calc(var(--h) * 0.0269)); /* 21px at 780 tall */
position: relative;
width: calc(var(--h) * (var(--ratio) + var(--gap) + var(--col)));
height: var(--h);
margin: 0;
cursor: pointer;
}
@media (max-width: 639px) {
.al-comp { --gap: 0.08; --col: 0.55; }
}
.al-photo {
position: absolute;
top: 0;
left: 0;
width: calc(var(--h) * var(--ratio));
height: 100%;
overflow: hidden;
border-radius: 6px;
background: #efefef;
}
.al-photo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.al-lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
/* On the photo: white lines; on the page: black */
.al-lines--photo line,
.al-lines--photo .ring { fill: none; stroke: rgba(255, 255, 255, 0.92); stroke-width: 1.6; }
.al-lines--photo .dot { fill: #fff; }
.al-lines--page line { stroke: #000; stroke-width: 1.2; }
.al-labels { position: absolute; inset: 0; pointer-events: none; }
.al-label {
position: absolute;
left: calc(var(--h) * (var(--ratio) + var(--gap)));
top: calc(var(--y) * 100% - var(--eyebrow) / 2); /* the eyebrow sits on its leader's height */
width: calc(var(--h) * var(--col));
opacity: 0;
will-change: opacity, transform;
}
.al-eyebrow {
display: block;
margin-bottom: calc(var(--eyebrow) * 0.9);
font-size: var(--eyebrow);
font-weight: 500;
line-height: 1;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #8e8e8e;
}
.al-text { display: block; font-size: var(--text); line-height: 1.35; letter-spacing: -0.01em; text-wrap: balance; }
</style>
</head>
<body>
<section class="al" aria-label="Annotated photograph">
<figure class="al-comp" id="comp">
<div class="al-photo" id="photo">
<!-- Swap the picsum.photos placeholder for your own portrait (3:4) photo,
then move each label's data-x / data-y onto the detail it describes. -->
<img src="https://picsum.photos/seed/cadence-1/1200/1600" alt="" width="1200" height="1600">
</div>
<svg class="al-lines al-lines--photo" id="linesPhoto" aria-hidden="true"></svg>
<svg class="al-lines al-lines--page" id="linesPage" aria-hidden="true"></svg>
<figcaption class="al-labels">
<div class="al-label" data-x="0.80" data-y="0.10"><span class="al-eyebrow">Light</span><span class="al-text">Soft overcast daylight</span></div>
<div class="al-label" data-x="0.55" data-y="0.45"><span class="al-eyebrow">Subject</span><span class="al-text">The centre of the frame</span></div>
<div class="al-label" data-x="0.86" data-y="0.68"><span class="al-eyebrow">Setting</span><span class="al-text">A quiet background</span></div>
<div class="al-label" data-x="0.66" data-y="0.905"><span class="al-eyebrow">Ground</span><span class="al-text">The lower edge</span></div>
</figcaption>
</figure>
</section>
<script>
(function () {
"use strict";
/* Constants */
const RING_IN = 0.5; // s, the ring lands
const RING_R = [3, 5.5]; // px, its radius from -> to
const DOT_R = 2.1; // px
const LINE_IN = 0.9; // s, the leader draws out
const RING_CLEAR = 11; // px between the anchor and the start of its leader
const LABEL_IN = [0.55, 1.3]; // s, the label fades in…
const LABEL_SLIDE = [0.55, 1.25]; // s, …and slides into place…
const LABEL_DX = 16; // px, …from this far to the right
const LEAD_GAP = 24; // px between a leader's end and its label (at 780px tall)
const FIRST = 0.4; // s, the first mark after the photo is in view
const STAGGER = 0.75; // s between marks
const HOLD = 2.5; // s, all marks held before the loop
const OUT = 0.5; // s, the marks fade out before they play again
const PHOTO_IN = 0.9; // s, the photo fades in the first time
/* Helpers */
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, p) => a + (b - a) * p;
const seg = (t, a, b) => clamp((t - a) / (b - a), 0, 1);
const smooth = (x) => x * x * (3 - 2 * x);
const expoOut = (x) => (x >= 1 ? 1 : 1 - Math.pow(2, -10 * x));
/* Elements */
const comp = document.getElementById("comp");
const photo = document.getElementById("photo");
const svgPhoto = document.getElementById("linesPhoto");
const svgPage = document.getElementById("linesPage");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const NS = "http://www.w3.org/2000/svg";
const marks = Array.from(comp.querySelectorAll(".al-label")).map((el, n) => {
const fx = parseFloat(el.dataset.x), fy = parseFloat(el.dataset.y);
el.style.setProperty("--y", fy);
const g = document.createElementNS(NS, "g");
g.innerHTML = '<line/><circle class="ring"/><circle class="dot"/>';
svgPhoto.appendChild(g);
const out = document.createElementNS(NS, "line");
svgPage.appendChild(out);
return {
el, fx, fy, g, out,
t0: FIRST + n * STAGGER,
line: g.querySelector("line"),
ring: g.querySelector(".ring"),
dot: g.querySelector(".dot"),
};
});
const T_ALL = marks[marks.length - 1].t0 + LABEL_IN[1]; // every mark is in
const T_OUT = T_ALL + HOLD;
const T_LOOP = T_OUT + OUT;
/* Layout: measured from the photo as it renders */
let geo = null;
function layout() {
const c = comp.getBoundingClientRect();
const p = photo.getBoundingClientRect();
const label = marks[0].el.offsetLeft; // the label column's left edge
const s = c.height / 780;
geo = { w: p.width, h: p.height, right: p.width, end: label - Math.max(12, LEAD_GAP * s) };
[svgPhoto, svgPage].forEach((svg) => svg.setAttribute("viewBox", "0 0 " + c.width + " " + c.height));
marks.forEach((m) => {
m.ax = m.fx * geo.w;
m.ay = m.fy * geo.h;
m.x1 = m.ax + RING_CLEAR;
m.L1 = Math.max(0, geo.right - m.x1); // on the photo
m.L2 = Math.max(0, geo.end - Math.max(m.x1, geo.right)); // on the page
m.line.setAttribute("x1", m.x1);
m.line.setAttribute("x2", m.x1 + m.L1);
m.out.setAttribute("x1", geo.end - m.L2);
m.out.setAttribute("x2", geo.end);
[m.line, m.out].forEach((l) => { l.setAttribute("y1", m.ay); l.setAttribute("y2", m.ay); });
m.line.setAttribute("stroke-dasharray", m.L1 + " " + (m.L1 + 1));
m.out.setAttribute("stroke-dasharray", m.L2 + " " + (m.L2 + 1));
[m.ring, m.dot].forEach((c2) => { c2.setAttribute("cx", m.ax); c2.setAttribute("cy", m.ay); });
m.dot.setAttribute("r", DOT_R);
});
}
/* render(t): every value read off one clock */
let firstRun = true;
function render(t) {
const fadeOut = 1 - smooth(seg(t, T_OUT, T_OUT + OUT));
photo.style.opacity = firstRun && !reduced ? smooth(seg(t, 0, PHOTO_IN)) : 1;
marks.forEach((m) => {
const ringIn = seg(t, m.t0, m.t0 + RING_IN);
const lineIn = expoOut(seg(t, m.t0, m.t0 + LINE_IN));
const lab = smooth(seg(t, m.t0 + LABEL_IN[0], m.t0 + LABEL_IN[1]));
const vis = t < m.t0 ? 0 : fadeOut;
m.g.style.opacity = vis;
m.out.style.opacity = vis;
m.ring.setAttribute("r", lerp(RING_R[0], RING_R[1], expoOut(ringIn)).toFixed(2));
m.ring.style.opacity = smooth(ringIn);
m.dot.style.opacity = smooth(ringIn);
// the leader draws out from the anchor: across the photo, then on along the page
const drawn = (m.L1 + m.L2) * lineIn;
m.line.setAttribute("stroke-dashoffset", (m.L1 - Math.min(drawn, m.L1)).toFixed(2));
m.out.setAttribute("stroke-dashoffset", (m.L2 - clamp(drawn - m.L1, 0, m.L2)).toFixed(2));
m.el.style.opacity = (lab * fadeOut).toFixed(3);
m.el.style.transform =
"translate3d(" + ((1 - expoOut(seg(t, m.t0 + LABEL_SLIDE[0], m.t0 + LABEL_SLIDE[1]))) * LABEL_DX).toFixed(2) + "px,0,0)";
});
}
/* The clock */
let t = 0, playing = false, last = 0, raf = 0;
function frame(now) {
const dt = Math.min((now - last) / 1000, 1 / 20);
last = now;
t += dt;
if (t >= T_LOOP) { t = 0; firstRun = false; }
render(t);
raf = requestAnimationFrame(frame);
}
function play() {
if (playing) return;
playing = true;
last = performance.now();
raf = requestAnimationFrame(frame);
}
function stop() {
playing = false;
cancelAnimationFrame(raf);
}
layout();
// Reduced motion: every mark shown at once, still.
if (reduced) {
render(T_ALL);
window.addEventListener("resize", () => { layout(); render(T_ALL); });
return;
}
render(0);
new ResizeObserver(() => { layout(); render(t); }).observe(comp);
// Play when the photo comes into view; start over the next time it does.
new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) play();
else { stop(); t = 0; render(0); }
});
},
{ threshold: 0.35 }
).observe(comp);
// Click: replay. While marks are on screen they fade out first, then play again.
comp.addEventListener("click", () => {
t = t > marks[0].t0 && t < T_OUT ? T_OUT : 0;
if (t === 0) firstRun = false;
render(t);
play();
});
})();
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/vce701823e78c4301a35b7e07294d87301792088814092" integrity="sha512-UKNk8GbxkABxKcXYuLkO16EleLGUkcvJWtpSNmExE0Too3ZaFAxTscNWEc1OdAv5/BikHawapSCaiD2XCmp+Bw==" data-cf-beacon='{"version":"2024.11.0","token":"c54fa41b83884ade97f239ecb41aee46","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>
원본 구현 프롬프트
# Annotation Lines: callouts that draw out from details on a photograph, one after another
**Goal:** build an annotated photograph where callouts appear in turn: a small ring lands on a detail, a level leader line draws from it to a label column beside the photo, and the label slides in. Each stays as the next arrives.
## Visual behaviour
Everything is computed from one clock `t` (seconds since the photo came into view) in a `render(t)` function, so the sequence can loop and replay cleanly.
1. **Layout.** A portrait photo (3:4) on the left with 6 px rounded corners, a clear gap, then a column of labels on the right. Each label has an anchor point on the photo given as fractions of the photo (`x`, `y` from 0 to 1). The label's eyebrow sits exactly at its anchor's height, and every leader is horizontal, so with anchors at different heights the lines never cross.
2. **Photo in.** The first time only, the photo fades in over 0.9 s (smoothstep).
3. **Marks, in turn.** Mark *n* starts at `t0 = 0.4 + n × 0.75` s. For each mark, from its `t0`:
- **Ring:** a circle at the anchor grows from radius 3 px to 5.5 px over 0.5 s (expo-out), fading in (smoothstep). A solid 2.1 px dot sits in its centre, fading in with it. Ring stroke and dot are white (ring stroke 1.6 px, 92% white).
- **Leader:** a horizontal line starting 11 px to the right of the anchor and running to 24 px before the label column (scale the 24 px with the composition height, minimum 12 px). It draws out over 0.9 s (expo-out) using a stroke dash: `stroke-dasharray: L L+1`, `stroke-dashoffset` from `L` to 0. The line has two parts that draw as one continuous stroke: the part over the photo is white (1.6 px), the part over the white page is black (1.2 px). Draw the photo part first, then continue on the page part with the same progress.
- **Label:** a small uppercase grey eyebrow (letter-spacing 0.25em) over a short line of text. It fades in from `t0 + 0.55` to `t0 + 1.3` s (smoothstep) and slides from 16 px to the right into place from `t0 + 0.55` to `t0 + 1.25` s (expo-out).
4. **Hold and loop.** When the last label is in (`T_all = t0_last + 1.3`), everything holds for 2.5 s, then all marks (rings, lines, labels) fade out together over 0.5 s (smoothstep), and the sequence starts again from `t = 0` (the photo stays visible).
5. **Viewport and click.** The clock starts when at least 35% of the composition is visible (IntersectionObserver). When it leaves view, stop and reset to `t = 0`. Clicking the composition replays: if marks are showing, jump to the fade-out first; otherwise restart at 0.
## Exact values
| What | Value |
|---|---|
| First mark | 0.4 s after in view |
| Stagger | 0.75 s between marks |
| Ring | r 3 → 5.5 px, 0.5 s, expo-out; dot r 2.1 px |
| Leader | starts 11 px right of the anchor, ends 24 px before the labels, 0.9 s, expo-out |
| Label fade | +0.55 → +1.3 s, smoothstep |
| Label slide | 16 px → 0, +0.55 → +1.25 s, expo-out |
| Hold / fade out | 2.5 s / 0.5 s |
| Photo first fade | 0.9 s |
| Line colours | white 1.6 px on the photo, black 1.2 px on the page |
Easing: `smooth(x) = x²(3 − 2x)`; `expoOut(x) = 1 − 2^(−10x)` (≈ `cubic-bezier(0.16, 1, 0.3, 1)`).
## Markup and layout
```html
<figure class="comp">
<div class="photo"><img src="portrait.jpg" alt="…"></div>
<svg class="lines lines--photo"></svg> <!-- rings, dots, the leader parts over the photo -->
<svg class="lines lines--page"></svg> <!-- the leader parts over the page -->
<figcaption class="labels">
<div class="label" data-x="0.80" data-y="0.10"><span class="eyebrow">Light</span><span class="text">Soft overcast daylight</span></div>
<div class="label" data-x="0.55" data-y="0.45">…</div>
…
</figcaption>
</figure>
```
- Size everything in units of the composition height `h`: photo width `h × 0.75`, gap `h × 0.17`, label column `h × 0.45`. `h = min(viewport height − margins, 780px, available width / (0.75 + 0.17 + 0.45))`.
- Eyebrow `max(9px, h × 0.0154)` (12 px at 780), text `max(12px, h × 0.0269)` (21 px at 780), line-height 1.35.
- Labels are absolutely positioned: `left = h × (0.75 + 0.17)`, `top = y × 100% − eyebrow / 2`.
- Both SVGs cover the whole composition (`viewBox` = its pixel size, `overflow: visible`). Compute anchors, line ends and dash lengths from the rendered photo size, and recompute on resize (ResizeObserver), so marks stay on their details at every size.
## Libraries
None: SVG lines and circles, `requestAnimationFrame` advancing `t` by the frame delta (capped at 1/20 s). GSAP could drive the same timeline (`drawSVG` or dashoffset tweens with `expo.out`), but the single `render(t)` keeps looping and replay simple.
## Responsive and reduced motion
- On narrow screens (< 640 px) use a shorter gap (`h × 0.08`) and a wider label column (`h × 0.55`).
- `prefers-reduced-motion: reduce`: render the final state (all marks and labels shown) once, with no animation.
## Acceptance criteria
- Marks appear one at a time, 0.75 s apart: ring, then the leader drawing out to the label column, then the label sliding in.
- Each leader is perfectly horizontal, white over the photo and black over the page, and reads as one continuous line.
- Rings sit exactly on their anchor details at any viewport size.
- After all labels are in, they hold 2.5 s, fade out together, and the sequence repeats.
- The sequence only runs while the composition is in view, and restarts from the beginning when it returns.
- With reduced motion, everything is visible and static.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 40267544b9dd894cc37ed00c66dcf4e381dce0681c571a4f20c59568f1dce88b