Skill 18 · Hyperframes Animation
Subchapter 18.99
rules/svg-icon-enrichment.mdMarkdown7 KBView on GitHub
Treats an SVG icon as a composition of animated PARTS, not an opaque image. Each meaningful internal element (a clock hand, scissor blade, recording dot, data line) gets its own micro-animation, targeted by id. Distinct from svg-path-draw (which animates the OUTLINE drawing) — enrichment animates INTERNAL PARTS, ideally after the outline has drawn.
Four signature patterns:
| Pattern | Use For | Math | Tip |
|---|---|---|---|
| Rotation | Clock, gear, loader, dial | rotate(deg cx cy) attribute, linear | see the transform-center gotcha |
| Oscillation | Scissors, wings, toggle | rotate(±sin·amp) on opposing groups | opposite signs on the two parts |
| Pulse |
| Recording dot, heart, notification |
scale(1 + sin·amp) + opacity |
| ring lags dot by π/2 for ripple |
| Dash flow | Cutting line, data stream | strokeDashoffset linear via time | negative for L→R, positive for R→L |
For rotation around an explicit point inside an SVG, use the SVG transform ATTRIBUTE, not CSS transform: el.setAttribute("transform", rotate(${deg} ${cx} ${cy})). The CSS combination transform: rotate(...) + transform-origin: 60px 60px + transform-box: fill-box interprets the origin in the element’s OWN bbox-local coordinates, NOT viewBox coordinates. For a thin <line> (whose bbox is the line’s narrow envelope), 60 60 bbox-local is a point OUTSIDE the line — the hand flies along an off-center arc instead of rotating in place. Same trap for small inner shapes (a dot circle whose bbox is the small circle, not the full viewBox).
Scaling around a center point: same attribute route — el.setAttribute("transform", translate(${cx} ${cy}) scale(${s}) translate(-${cx} -${cy})).
<!-- inside a standard scene clip — named children are the animation targets -->
<svg class="icon-svg" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<circle cx="60" cy="60" r="50" fill="none" stroke="{accentColor}" stroke-width="6" />
<line
id="hand-min"
x1="60"
y1="60"
x2="60"
y2="22"
stroke="{textColor}"
stroke-width="6"
stroke-linecap="round"
/>
<line
id="hand-sec"
x1="60"
y1="60"
x2="60"
y2="30"
stroke="{recordColor}"
stroke-width="3"
stroke-linecap="round"
/>
<circle cx="60" cy="60" r="6" fill="{textColor}" />
</svg>
<!-- pulse icon: #rec-ring + #rec-dot circles; dash-flow: a <line> with stroke-dasharray="14 12" -->// Pattern 1 — Rotation. Proxy tween → SVG transform attribute (explicit center, see gotcha).
const hand = document.getElementById("hand-min");
const minState = { deg: 0 };
tl.to(
minState,
{
deg: 360 * MIN_REVOLUTIONS,
duration: TOTAL_DURATION,
ease: "none", // linear motion is the point
onUpdate: () => hand.setAttribute("transform", `rotate(${minState.deg} 60 60)`),
},
0,
);
// second hand: same shape with SEC_REVOLUTIONS (visibly faster).
// Pattern 3 — Pulse. One phase proxy drives dot + ring, ring offset by π/2.
const dot = document.getElementById("rec-dot");
const ring = document.getElementById("rec-ring");
const pulse = { p: 0 };
tl.to(
pulse,
{
p: Math.PI * 2 * PULSE_CYCLES,
duration: TOTAL_DURATION,
ease: "none", // sine handles the curve
onUpdate: () => {
const sD = 1 + Math.sin(pulse.p) * PULSE_DOT_AMP;
const sR = 1 + Math.sin(pulse.p + Math.PI / 2) * PULSE_RING_AMP;
dot.setAttribute("transform", `translate(60 60) scale(${sD}) translate(-60 -60)`);
ring.setAttribute("transform", `translate(60 60) scale(${sR}) translate(-60 -60)`);
ring.style.opacity = String(
PULSE_RING_OPACITY_BASE + Math.sin(pulse.p) * PULSE_RING_OPACITY_AMP,
);
},
},
0,
);
// Pattern 4 — Dash flow. Linear offset tween on a dashed stroke.
const flowState = { offset: 0 };
tl.to(
flowState,
{
offset: DASH_FLOW_TOTAL_OFFSET, // negative = L→R
duration: TOTAL_DURATION,
ease: "none",
onUpdate: () => {
document.getElementById("data-flow").style.strokeDashoffset = String(flowState.offset);
},
},
0,
);0 → OUTLINE_DUR), then start enrichment at OUTLINE_DUR: the icon “wakes up” after assembly.| token | range | notes |
|---|---|---|
| MIN_REVOLUTIONS | 0.5–2.0 | avoid integer revolutions if the end frame is visible (lands back at start) |
| SEC_REVOLUTIONS | 4–10 | > MIN × 3 or the speed difference doesn’t read |
| PULSE_CYCLES | 2–4 over a 3–5s comp | ≥5 reads as anxious flicker; ≤1 reads as forgotten |
| PULSE_DOT_AMP | 0.05–0.20 | 0.05 = breathing; 0.20 = throbbing |
| PULSE_RING_AMP | 0.04–0.12 | must be < PULSE_DOT_AMP or the ring overshadows the dot |
| PULSE_RING_OPACITY_BASE / _AMP | 0.4–0.6 / 0.3–0.5 | BASE − AMP ≥ 0 and BASE + AMP ≤ 1 |
| DASH_FLOW_TOTAL_OFFSET | ±100–400 | must be an integer multiple of the dash period (dash + gap) or the end frame shows a phase jump |
transform attribute for any rotation/scale around an explicit interior point; never CSS transform-origin + transform-box: fill-box on thin lines or small inner shapes.requestAnimationFrame — like CSS animation, it desyncs from HF’s frame-by-frame seek; continuous motion lives inside the timeline as linear proxy tweens.stroke-linecap: round on flowing/dashed lines for clean dash edges.svg-path-draw (outline draws first, enrichment second) · orbit-3d-entry (orbiting items are enriched icons) · sine-wave-loop (the whole icon floats while internal parts animate).