--- name: svg-icon-enrichment description: Animate internal SVG elements (rotating hands, opening blades, pulsing dots, dash flows) to make icons feel alive without replacing them. metadata: tags: svg, icon, animation, internal, micro-animation, pulse, rotation --- # SVG Icon Enrichment 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](svg-path-draw.md) (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 | ## ❗ The transform-center gotcha **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 `` (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})`)`. ## Recipe ```html ``` ```js // 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, ); ``` ## Variations - **Stroke draw → enrichment chain** — draw the outline first via [svg-path-draw](svg-path-draw.md) (phase 1, `0 → OUTLINE_DUR`), then start enrichment at `OUTLINE_DUR`: the icon "wakes up" after assembly. - **Per-icon entry stagger** — for a row of icons, each icon's enrichment starts as it fades in, not synchronized. ## Values | 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 | ## Critical Constraints - **The transform-center gotcha above** — SVG `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. - **No `requestAnimationFrame`** — like CSS animation, it desyncs from HF's frame-by-frame seek; continuous motion lives inside the timeline as linear proxy tweens. - **Amplitudes subtle** — icons are decorative, not headlines; calibrate rotation speed against composition length, not absolute time. - **Phase-offset the parts** — minute vs second hand at different speeds, ring lagging dot by π/2. Pure sync looks mechanical. - **`stroke-linecap: round`** on flowing/dashed lines for clean dash edges. - **Climax dwell ≥1s** — if the enrichment is the headline beat, the composition continues ≥1s after the most dramatic moment. ## See also `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).