Pendulum
Damped swing wrapper with imperative control.
Live demo
01Hanging shop sign β viewport trigger
Notification bell β click trigger
β Tap or focus + Enter
Imperative swing() β manual trigger
Staggered viewport reveal β autoStartDelay
Live controls
Implementation
02<script>
import Pendulum from '$lib/components/Pendulum.svelte';
</script>
β
<Pendulum trigger="viewport" amplitude={18} pivotOffset={{ x: 0, y: -40 }}>
<img src="/shop-sign.png" alt="The Crown Tavern" />
</Pendulum>Pendulum wraps any element and animates a damped harmonic swing about a configurable pivot. The motion is driven by a single rAF loop that ticks an analytic damped sine β no animation library, no spring solver. Triggers cover mount, viewport entry, click, and an imperative swing()/stop() API for parent-controlled choreography.
Logic explainer
03What Does It Do? (Plain English)
Pendulum wraps any element and makes it swing back and forth on a damped harmonic curve, like a pendulum hanging from a configurable pivot point above the element. The first swing is the largest, each successive swing is smaller, and after a few seconds the motion fades to rest. Four trigger modes decide when the swing starts: on mount (immediately), on viewport entry (when the user scrolls to it), on click (tap to swing again), or fully manually (consumer calls swing() themselves).
It is the kind of small flourish that adds personality to icons, badges, or callout cards without becoming distracting β the swing happens once, settles, and stays still until something triggers the next one. Reduced-motion users see a static element; the rAF loop never starts and the CSS transform stays at identity.
How It Works (Pseudo-Code)
state:
angle = 0 // current swing angle in degrees
reduced = false // capability flag
rafId = null // current animation frame
startTs = null // performance.now() when the current swing began
observer // IntersectionObserver for viewport trigger
derived:
safeTrigger = pickTrigger(trigger)
originCSS = pivotOffsetCSS(pivotOffset) // "calc(50% + Xpx) calc(50% + Ypx)"
tick(now):
if startTs === null: startTs = now
elapsedMs = now - startTs
elapsedS = elapsedMs / 1000
raw = dampedSine(elapsedS, amplitude, frequency, decay)
angle = clampSwing(raw, amplitude)
envelope = exp(-decay * elapsedS) * amplitude
halted = envelope < 0.05 or elapsedMs >= duration
if halted:
angle = 0; rafId = null; startTs = null; return
rafId = requestAnimationFrame(tick)
export swing():
if rafId: cancelAnimationFrame(rafId); rafId = null
startTs = null
if reduced: angle = 0; return
rafId = requestAnimationFrame(tick)
export stop():
if rafId: cancelAnimationFrame(rafId)
rafId = null; startTs = null; angle = 0
on mount:
reduced = isReducedMotion()
if reduced: return
if trigger === 'mount': swing() (after autoStartDelay)
if trigger === 'viewport': observer.observe(containerEl)
on intersect β swing()
on click (only when trigger === 'click'):
swing()
on destroy: cancelAnimationFrame, clearTimeout, observer.disconnect
render:
div.pendulum style:transform-origin={originCSS}
div.pendulum__inner style="--pendulum-angle: {angle}deg"
{ children }
CSS:
.pendulum__inner {
transform: rotateZ(var(--pendulum-angle, 0deg));
transform-origin: inherit;
will-change: transform;
}The Core Concept: Damped Harmonic Motion (The Math!)
The swing curve is the analytical solution to a damped harmonic oscillator:
ΞΈ(t) = A Β· e^(-decayΒ·t) Β· cos(2Ο Β· frequency Β· t)
where:
A = amplitude (peak swing in degrees, e.g. 18)
frequency = swing rate in Hz (e.g. 1.2 Hz = ~1.2 swings per second)
decay = exponential damping (1/sec; higher = settles faster)
t = elapsed time in secondsThe cosine is the oscillation. Multiplying by e^(-decayΒ·t) is what makes the amplitude shrink each cycle β at t = 0, the envelope is A; at t = 1 with decay = 1.4, the envelope is A Γ e^(-1.4) β 0.247 Γ A. After ~2 seconds the amplitude is below 5% and the motion is visually still.
ΞΈ(t)
A β
β ββ ββ
ββ ββ
0β ββββββββββββββββββββββββββββ βββ β time
β ββ βββ βββββ
β βββββββ
-A
β β β β β
each peak is smaller as e^(-decayΒ·t) shrinksThree deliberate decisions in the implementation:
- Use the analytical solution, not numerical integration.
dampedSine(t, A, f, decay)evaluates the curve at anytdirectly β no accumulated floating-point drift, no need to integrate a differential equation. The component still uses rAF, but only to sample the analytical curve at the current frame's timestamp. - Halt early when the envelope drops below 0.05Β°. No point continuing to schedule rAF frames for sub-pixel rotations the eye can't see. The check
envelope < 0.05exits the loop cleanly. - Clamp the result.
clampSwing(raw, amplitude)is defensive β even though the analytical solution can never exceedΒ±amplitude, a non-finite input would otherwise propagateNaNdeginto the inline transform.
A second helper, nextAngle(state, deltaT, gravity, damping), is exported for consumers who want to drive their own physics loop with Euler integration instead of the analytical solution. The component itself doesn't use it β but it's there for tests and for consumers who want to couple Pendulum's motion to a custom force model.
The pivot offset is the other piece of geometry. By default the pivot is at {x:0, y:-20} β the centre of the element's bounding box, shifted 20 px upward. That makes the element swing as if hanging from a string attached 20 px above its top. pivotOffsetCSS({x, y}) translates this into the CSS transform-origin: calc(50% + Xpx) calc(50% + Ypx) β anchoring rotation around that offset point.
CSS Animation Strategy
The component is CSS-light. One transform, one will-change hint:
.pendulum__inner {
display: inline-block;
transform: rotateZ(var(--pendulum-angle, 0deg));
transform-origin: inherit; /* picks up calc(50% + ...) from the wrapper */
will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
.pendulum__inner {
transform: none !important;
transition: none !important;
}
}transform-origin is set on the outer .pendulum via style:transform-origin={originCSS} and inherited into the inner β this lets the JS update the angle on the inner without recalculating the origin. The reduced-motion @media rule is a stylesheet-level safety net even if the JS gate is somehow bypassed.
There is no CSS keyframe β the JS writes a fresh --pendulum-angle per frame, and the GPU compositor handles the rotation. This is the right call here because dampedSine is not a curve CSS can express in keyframes (the exponential envelope is not in cubic-bezier's vocabulary). The cost is "one rAF until envelope < threshold", which is bounded by duration (4 s default) regardless of input.
Performance
- Idle: zero. No timer, no rAF, no observer (unless
viewporttrigger is in use). - Per swinging frame: one
dampedSineevaluation (oneMath.exp, oneMath.cos, three multiplications), one$statewrite (angle), one CSS variable update. Sub-millisecond at 60 fps. - Auto-halt: when the envelope drops below 0.05Β° or
durationms is reached, the loop exits and rAF is unscheduled. No idle scheduling. - Viewport trigger: a single
IntersectionObserveron the container. Disconnects on first intersection (one-shot).
State Flow Diagram
ββββββββββββββββββββ
β REST β β angle = 0, rafId = null
ββββββββββ¬ββββββββββ
β
trigger='mount' β trigger='viewport' trigger='click' trigger='manual'
β
βΌ
autoStartDelay timer waiting on observer waiting on click waiting on swing()
β
βΌ swing() called
ββββββββββββββββββββ
β SWINGING β β rAF loop active
β angle = β evaluating dampedSine
β dampedSine(t,β¦) β each frame
ββββββββββ¬ββββββββββ
β envelope < 0.05Β° or elapsed >= duration
βΌ
ββββββββββββββββββββ
β HALTED β β angle = 0, rafId = null
ββββββββββ¬ββββββββββ
β
βΌ β REST (next trigger restarts)
prefers-reduced-motion: reduce β SWINGING never entered. Stays in REST.
swing() while in SWINGING β cancelAnimationFrame, reset startTs, restart loop.Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
trigger |
'mount' | 'viewport' | 'click' | 'manual' |
'mount' |
When the swing starts. 'click' makes the wrapper a button. 'manual' means consumer calls swing(). |
amplitude |
number |
18 |
Peak swing in degrees. |
frequency |
number |
1.2 |
Swing rate in Hz (cycles per second). |
decay |
number |
1.4 |
Exponential damping (1/sec). Higher = settles faster. |
pivotOffset |
{ x: number; y: number } |
{ x: 0, y: -20 } |
Pivot offset in pixels from the element's centre. |
duration |
number |
4000 |
Hard cap on swing duration in ms. The envelope check usually exits sooner. |
autoStartDelay |
number |
0 |
For mount trigger: ms to wait before starting. |
threshold |
number |
0.4 |
For viewport trigger: IntersectionObserver threshold. |
class |
string |
'' |
Extra wrapper classes. |
children |
Snippet |
β | The element to wrap. |
The component exports swing() and stop() for bind:this={pendulum} imperative control.
Edge Cases
| Situation | Behaviour |
|---|---|
prefers-reduced-motion: reduce |
rAF loop never starts. swing() short-circuits with angle = 0. Stylesheet @media rule pins transform to identity. |
swing() called while already swinging |
Existing rAF cancels; startTs resets; the loop restarts from the new t = 0. The user sees a fresh swing, not a continuation of the previous one. |
frequency = 0 |
The cosine becomes cos(0) = 1 constant; the envelope decays an unwavering offset. Visually: the element shifts by amplitude and slowly returns to 0. Acceptable; useful for "lean and settle" motions. |
decay = 0 |
No damping. The cosine oscillates forever at full amplitude. The duration cap is what eventually halts it; consider raising duration or setting a non-zero decay for non-perpetual swings. |
Non-finite input to dampedSine |
Returns 0. Guards downstream against NaN propagation. |
| Component destroys mid-swing | onDestroy cancels the rAF and the autoStartDelay timer, disconnects the observer. No leaked anything. |
trigger = 'click' with keyboard user |
The clickable inner wrapper is role="button", tabindex="0", with Enter/Space handlers β full keyboard parity. |
trigger = 'viewport' and component never enters viewport |
Observer waits indefinitely. No swing fires; no resource leak. |
| Multiple Pendulum instances on one page | Each is independent. They drift out of phase even with identical config β usually preferable to synchronised swinging. |
pivotOffset = { x: 0, y: 0 } |
Pivot at element centre. Swing rotates the element about its own centroid β looks like a teetering coin rather than a hanging pendulum. |
Dependencies
- Svelte 5 β
$state,$derived,$props,onMount,onDestroy,Snippet. <script module>exports βdampedSine,nextAngle,clampSwing,pickTrigger,pivotOffsetCSS,isReducedMotion. All pure, deterministic, testable without a DOM.IntersectionObserverβ browser primitive used for the viewport trigger only.- Zero external libraries β no animation library, no physics library. The
dampedSinecurve isMath.expΓMath.cos.
File Structure
src/lib/components/Pendulum.svelte # implementation
src/lib/components/Pendulum.md # this explainer
src/lib/components/Pendulum.test.ts # unit tests for exported helpers
src/routes/pendulum/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
trigger | 'mount' | 'viewport' | 'click' | 'manual' | 'mount' | What kicks off the swing. |
amplitude | number | 18 | Maximum swing angle in degrees. |
frequency | number | 1.2 | Oscillation frequency in Hz. |
decay | number | 1.4 | Damping rate per second. Higher = faster settle. |
pivotOffset | { x: number; y: number } | { x: 0, y: -20 } | Pivot point relative to the wrapped element centre. |
duration | number | 4000 | Hard cap on swing duration in ms. |
autoStartDelay | number | 0 | Delay before auto-triggers fire (ms). |
threshold | number | 0.4 | IntersectionObserver visibility ratio for the 'viewport' trigger. |
swing() / stop() | method | β | Imperative controls available via bind:this. |