Files
heygen-com--hyperframes/skills/hyperframes-animation/rules/svg-icon-enrichment.md
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

330 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 GSAP-driven micro-animation. Distinct from [svg-path-draw](svg-path-draw.md) (which animates the OUTLINE drawing) — enrichment animates INTERNAL PARTS, ideally after the outline has drawn.
## How It Works
The SVG is authored with named `<line>`, `<circle>`, `<path>`, or `<g>` children. The GSAP timeline targets these by selector and applies one of 4 signature motion patterns:
1. **Rotation** — clock hand, gear, loading spinner (`transform: rotate(deg)`)
2. **Oscillation** — scissor blades, wing flap, toggle (`transform: rotate(±sin*amp)` on opposing groups)
3. **Pulse** — recording dot, heart, notification (`scale + opacity` via sin)
4. **Dash flow** — moving dashes along a stroke, like a data stream (`strokeDashoffset` linear)
All run inside the paused GSAP timeline so HF seeks deterministically.
## HTML
```html
<div
class="scene"
data-composition-id="enrichment-scene"
data-start="0"
data-duration="4"
data-track-index="0"
>
<div class="stack">
<div class="row">
<!-- Clock icon — minute hand rotates -->
<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
class="clock-hand"
id="hand-min"
x1="60"
y1="60"
x2="60"
y2="22"
stroke="{textColor}"
stroke-width="6"
stroke-linecap="round"
/>
<line
class="clock-hand"
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>
<!-- Recording dot — pulses -->
<svg class="icon-svg" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<circle
id="rec-ring"
cx="60"
cy="60"
r="50"
fill="none"
stroke="{recordColor}"
stroke-width="4"
/>
<circle id="rec-dot" cx="60" cy="60" r="22" fill="{recordColor}" />
</svg>
<!-- Data stream — dashes flow along the line -->
<svg class="icon-svg" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<rect
x="14"
y="48"
width="92"
height="24"
rx="12"
fill="none"
stroke="{accentColor}"
stroke-width="4"
/>
<line
id="data-flow"
x1="14"
y1="60"
x2="106"
y2="60"
stroke="{accentColor}"
stroke-width="6"
stroke-linecap="round"
stroke-dasharray="14 12"
/>
</svg>
</div>
<div class="brand">{brandPhrase}</div>
</div>
</div>
```
## CSS
```css
.scene {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
background: {bgColor};
font-family: {font};
}
.stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 80px;
}
.row {
display: flex;
gap: 120px;
}
.icon-svg {
width: 320px;
height: 320px;
filter: drop-shadow(0 12px 32px {shadowColor});
}
.clock-hand {
/* transform-origin in SVG must be in viewBox units, not pixels */
transform-origin: 60px 60px;
transform-box: fill-box;
}
.brand {
font-size: 64px;
font-weight: 900;
letter-spacing: 14px;
text-transform: uppercase;
color: {textColor};
}
```
## GSAP Timeline
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// Pattern 1 — Rotation (clock hands)
// Minute hand: MIN_REVOLUTIONS full rotations over TOTAL_DURATION
const minState = { deg: 0 };
tl.to(
minState,
{
deg: 360 * MIN_REVOLUTIONS,
duration: TOTAL_DURATION,
ease: "none",
onUpdate: () => {
document.getElementById("hand-min").style.transform = `rotate(${minState.deg}deg)`;
},
},
0,
);
// Second hand: SEC_REVOLUTIONS full rotations over TOTAL_DURATION (faster)
const secState = { deg: 0 };
tl.to(
secState,
{
deg: 360 * SEC_REVOLUTIONS,
duration: TOTAL_DURATION,
ease: "none",
onUpdate: () => {
document.getElementById("hand-sec").style.transform = `rotate(${secState.deg}deg)`;
},
},
0,
);
// Pattern 2 — Pulse (recording dot, ring opacity inverse)
const pulseState = { p: 0 };
tl.to(
pulseState,
{
p: Math.PI * 2 * PULSE_CYCLES,
duration: TOTAL_DURATION,
ease: "none",
onUpdate: () => {
const dotScale = 1 + Math.sin(pulseState.p) * PULSE_DOT_AMP;
const ringScale = 1 + Math.sin(pulseState.p + Math.PI / 2) * PULSE_RING_AMP;
const ringOpacity =
PULSE_RING_OPACITY_BASE + Math.sin(pulseState.p) * PULSE_RING_OPACITY_AMP;
const dot = document.getElementById("rec-dot");
const ring = document.getElementById("rec-ring");
dot.style.transform = `scale(${dotScale})`;
dot.style.transformOrigin = "60px 60px";
ring.style.transform = `scale(${ringScale})`;
ring.style.transformOrigin = "60px 60px";
ring.style.opacity = String(ringOpacity);
},
},
0,
);
// Pattern 3 — Dash flow (data stream)
const flowState = { offset: 0 };
tl.to(
flowState,
{
offset: DASH_FLOW_TOTAL_OFFSET, // negative for L→R flow, positive for R→L
duration: TOTAL_DURATION,
ease: "none",
onUpdate: () => {
document.getElementById("data-flow").style.strokeDashoffset = String(flowState.offset);
},
},
0,
);
// Brand fades in early
tl.from(".brand", { opacity: 0, y: 16, duration: 0.6, ease: "power3.out" }, BRAND_AT);
window.__timelines["enrichment-scene"] = tl;
</script>
```
## How to Choose Values
- **MIN_REVOLUTIONS** — minute-hand revolutions across TOTAL_DURATION
- Range: 0.52.0 (continuous; faster reads as time-lapse)
- Constraints: avoid integer revolutions if visible end frame matters (lands back at start)
- **SEC_REVOLUTIONS** — second-hand revolutions across TOTAL_DURATION
- Range: 410 (should be visibly faster than the minute hand)
- Constraints: SEC_REVOLUTIONS > MIN_REVOLUTIONS × 3 for the speed difference to read
- **PULSE_CYCLES** — number of pulse cycles across TOTAL_DURATION
- Range: 24 over a 35 s comp
- Effects: ≥ 5 reads as anxious flicker; ≤ 1 reads as forgotten
- **PULSE_DOT_AMP** — dot scale amplitude
- Range: 0.050.20
- Effects: 0.05 = breathing; 0.20 = throbbing
- **PULSE_RING_AMP** — ring scale amplitude (typically lower than DOT_AMP)
- Range: 0.040.12
- Constraints: must be < PULSE_DOT_AMP or ring overshadows dot
- **PULSE_RING_OPACITY_BASE / PULSE_RING_OPACITY_AMP** — ring opacity baseline + sine amplitude
- Range: BASE 0.40.6; AMP 0.30.5
- Constraints: BASE AMP ≥ 0 and BASE + AMP ≤ 1
- **DASH_FLOW_TOTAL_OFFSET** — total stroke-dashoffset change across TOTAL_DURATION
- Range: 400 to 100 (negative for L→R) or +100 to +400 (R→L)
- Effects: |large| = fast flow; |small| = slow drift
- Constraints: must be an integer multiple of the dash period (dash + gap) or the loop end frame shows a phase jump
- **BRAND_AT** — when the brand phrase fades in
- Range: 0.31.0 s
- Effects: too early competes with icon entries; too late feels appended
- **Ease family choices**: rotation = `none` (linear motion is the point); pulse driver = `none` (sine handles the curve); reveal of brand = `power3.out`
## Signature Motion Patterns
| Pattern | Use For | Math | Tip |
| ----------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------- |
| Rotation | Clock, gear, loader, dial | `transform: rotate(deg)`, linear via sec-counter | `transform-origin` in viewBox units |
| 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 |
## Variations
### Stroke draw → enrichment chain
Draw the icon outline first (via [svg-path-draw](svg-path-draw.md)), THEN activate enrichment. The internal animation feels like "the icon woke up" after assembly.
```js
// Phase 1: outline draws (0 → OUTLINE_DUR)
tl.fromTo(
"#icon-outline",
{ strokeDashoffset: 360 },
{ strokeDashoffset: 0, duration: OUTLINE_DUR, ease: "power2.inOut" },
0,
);
// Phase 2: enrichment starts at OUTLINE_DUR
```
### Per-icon entry stagger
For a row of icons all animating, stagger their entries. Each icon's enrichment starts as it fades in, not synchronized — feels organic.
## Key Principles
- **❗ For rotation around an explicit point inside 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 in viewBox coordinates. For a thin `<line>` (whose bbox is the line's narrow envelope), `60 60` in bbox-local refers to a point OUTSIDE the line, so the hand flies along an off-center arc instead of rotating in place. Same trap for small inner shapes (rec-dot circle whose bbox is the small circle, not the full viewBox).
- **For scaling around a center point inside SVG**, use `el.setAttribute('transform', `translate(${cx} ${cy}) scale(${s}) translate(-${cx} -${cy})`)`. Same reason — avoids the CSS bbox-local origin trap.
- **Run continuous animations inside the timeline** — never CSS `@keyframes` or `requestAnimationFrame`. Both desync from HF's frame-by-frame seek.
- **Amplitudes subtle** — icons are decorative, not headlines. Pulse scale within the ranges above; rotation speeds calibrated against composition length, not absolute time.
- **Multiple parts of the same icon at different phases** — clock minute vs second hand at different speeds, ring vs dot pulse offset by π/2. Pure-sync looks mechanical; phase-offset looks alive.
- **❗ Climax dwell ≥ 1 s** — if the enrichment is the headline beat, the composition must continue ≥ 1 s after the most dramatic moment.
## Critical Constraints
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
- **Registry key = `data-composition-id`**
- **No CSS `animation`** on SVG children — must be timeline-driven
- **`transform-origin` matters per child** — set explicitly per animated element
- **`stroke-linecap: round`** on flowing/dashed lines for clean dash edges
- **Target SVG children by id** — `document.getElementById` is fine; selector chains into `<svg>` work the same as HTML
## Combinations
- [svg-path-draw.md](svg-path-draw.md) — outline draws first, enrichment activates second
- [orbit-3d-entry.md](orbit-3d-entry.md) — orbiting items are enriched icons (clock orbits a brand label)
- [sine-wave-loop.md](sine-wave-loop.md) — entire icon floats while internal parts animate
## Pairs with HF skills
- `/hyperframes-animation` — onUpdate writes transform/opacity per SVG child
- `/hyperframes-core` — composition wiring
- `/hyperframes-cli``hyperframes lint`