a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * Shift plugin for CSS keyframe animations
3 * Provides reusable animation presets that can be triggered on demand
4 */
5
6import { prefersReducedMotion } from "$core/transitions";
7import type { Optional } from "$types/helpers";
8import type { AnimationPreset, PluginContext, Signal } from "$types/volt";
9
10/**
11 * Registry of animation presets
12 */
13const animationRegistry = new Map<string, AnimationPreset>();
14const keyframeRegistry = new Map<string, string>();
15
16let keyframeSheet: Optional<CSSStyleSheet>;
17let keyframeCounter = 0;
18
19/**
20 * Built-in animation presets with CSS keyframes
21 */
22const builtinAnimations: Record<string, AnimationPreset> = {
23 bounce: {
24 keyframes: [
25 { offset: 0, transform: "translateY(0)" },
26 { offset: 0.25, transform: "translateY(-20px)" },
27 { offset: 0.5, transform: "translateY(0)" },
28 { offset: 0.75, transform: "translateY(-10px)" },
29 { offset: 1, transform: "translateY(0)" },
30 ],
31 duration: 100,
32 iterations: 1,
33 timing: "ease",
34 },
35 shake: {
36 keyframes: [
37 { offset: 0, transform: "translateX(0)" },
38 { offset: 0.1, transform: "translateX(-10px)" },
39 { offset: 0.2, transform: "translateX(10px)" },
40 { offset: 0.3, transform: "translateX(-10px)" },
41 { offset: 0.4, transform: "translateX(10px)" },
42 { offset: 0.5, transform: "translateX(-10px)" },
43 { offset: 0.6, transform: "translateX(10px)" },
44 { offset: 0.7, transform: "translateX(-10px)" },
45 { offset: 0.8, transform: "translateX(10px)" },
46 { offset: 0.9, transform: "translateX(-10px)" },
47 { offset: 1, transform: "translateX(0)" },
48 ],
49 duration: 500,
50 iterations: 1,
51 timing: "ease",
52 },
53 pulse: {
54 keyframes: [{ offset: 0, transform: "scale(1)", opacity: "1" }, {
55 offset: 0.5,
56 transform: "scale(1.05)",
57 opacity: "0.9",
58 }, { offset: 1, transform: "scale(1)", opacity: "1" }],
59 duration: 1000,
60 iterations: Number.POSITIVE_INFINITY,
61 timing: "ease-in-out",
62 },
63 spin: {
64 keyframes: [{ offset: 0, transform: "rotate(0deg)" }, { offset: 1, transform: "rotate(360deg)" }],
65 duration: 1000,
66 iterations: Number.POSITIVE_INFINITY,
67 timing: "linear",
68 },
69 flash: {
70 keyframes: [{ offset: 0, opacity: "1" }, { offset: 0.25, opacity: "0" }, { offset: 0.5, opacity: "1" }, {
71 offset: 0.75,
72 opacity: "0",
73 }, { offset: 1, opacity: "1" }],
74 duration: 1000,
75 iterations: 1,
76 timing: "linear",
77 },
78};
79
80function initBuiltinAnimations(): void {
81 for (const [name, preset] of Object.entries(builtinAnimations)) {
82 animationRegistry.set(name, preset);
83 }
84}
85
86initBuiltinAnimations();
87
88/**
89 * Register a custom animation preset.
90 * Allows users to define their own named animations in programmatic mode.
91 *
92 * @param name - Animation name (used in data-volt-shift="name")
93 * @param preset - Animation configuration with keyframes and timing
94 *
95 * @example
96 * ```typescript
97 * registerAnimation('wiggle', {
98 * keyframes: [
99 * { offset: 0, transform: 'rotate(0deg)' },
100 * { offset: 0.25, transform: 'rotate(-5deg)' },
101 * { offset: 0.75, transform: 'rotate(5deg)' },
102 * { offset: 1, transform: 'rotate(0deg)' }
103 * ],
104 * duration: 300,
105 * iterations: 2,
106 * timing: 'ease-in-out'
107 * });
108 * ```
109 */
110export function registerAnimation(name: string, preset: AnimationPreset): void {
111 if (animationRegistry.has(name) && Object.hasOwn(builtinAnimations, name)) {
112 console.warn(`[Volt] Overriding built-in animation preset: "${name}"`);
113 }
114 animationRegistry.set(name, preset);
115}
116
117/**
118 * Get an animation preset by name.
119 * Checks both custom and built-in presets.
120 *
121 * @param name - Preset name
122 * @returns Animation preset or undefined if not found
123 */
124export function getAnimation(name: string): Optional<AnimationPreset> {
125 return animationRegistry.get(name);
126}
127
128/**
129 * Check if an animation preset exists.
130 *
131 * @param name - Preset name
132 * @returns true if the preset is registered
133 */
134export function hasAnimation(name: string): boolean {
135 return animationRegistry.has(name);
136}
137
138/**
139 * Unregister a custom animation preset.
140 * Built-in presets cannot be unregistered.
141 *
142 * @param name - Preset name
143 * @returns true if the preset was removed, false otherwise
144 */
145export function unregisterAnimation(name: string): boolean {
146 if (Object.hasOwn(builtinAnimations, name)) {
147 console.warn(`[Volt] Cannot unregister built-in animation preset: "${name}"`);
148 return false;
149 }
150 return animationRegistry.delete(name);
151}
152
153/**
154 * Get all registered animation preset names.
155 *
156 * @returns Array of preset names
157 */
158export function getRegisteredAnimations(): string[] {
159 return [...animationRegistry.keys()];
160}
161
162type ParsedShiftValue = { animationName: string; duration?: number; iterations?: number; signalPath?: string };
163
164/**
165 * Parse shift plugin value to extract configuration.
166 * Supports:
167 * - "animationName" - default animation
168 * - "animationName.duration" - custom duration
169 * - "animationName.duration.iterations" - custom duration and iterations
170 * - "signalPath:animationName" - watch signal with animation
171 * - "signalPath:animationName.duration.iterations" - watch signal with custom settings
172 */
173function parseShiftValue(value: string): Optional<ParsedShiftValue> {
174 const colonIndex = value.indexOf(":");
175
176 if (colonIndex !== -1) {
177 const signalPath = value.slice(0, colonIndex).trim();
178 const animationPart = value.slice(colonIndex + 1).trim();
179 const parsed = parseAnimationValue(animationPart);
180
181 if (!parsed) {
182 return undefined;
183 }
184
185 return { ...parsed, signalPath };
186 }
187
188 return parseAnimationValue(value);
189}
190
191function parseAnimationValue(value: string): Optional<ParsedShiftValue> {
192 const parts = value.split(".");
193 const animationName = parts[0]?.trim();
194
195 if (!animationName) {
196 return undefined;
197 }
198
199 const result: ParsedShiftValue = { animationName };
200
201 if (parts.length > 1) {
202 const duration = Number.parseInt(parts[1], 10);
203 if (!Number.isNaN(duration)) {
204 result.duration = duration;
205 }
206 }
207
208 if (parts.length > 2) {
209 const iterations = Number.parseInt(parts[2], 10);
210 if (!Number.isNaN(iterations)) {
211 result.iterations = iterations;
212 }
213 }
214
215 return result;
216}
217
218function stopAnimation(el: HTMLElement): void {
219 el.style.animation = "";
220 el.style.animationName = "";
221 el.style.animationDuration = "";
222 el.style.animationTimingFunction = "";
223 el.style.animationIterationCount = "";
224 el.style.animationFillMode = "";
225 restoreOriginalDisplay(el);
226}
227
228function applyAnimation(el: HTMLElement, preset: AnimationPreset, duration?: number, iterations?: number): void {
229 if (prefersReducedMotion()) {
230 return;
231 }
232
233 const effectiveDuration = duration ?? preset.duration;
234 const effectiveIterations = iterations ?? preset.iterations;
235 const animationName = getOrCreateKeyframes(preset);
236 if (!animationName) {
237 return;
238 }
239
240 ensureInlineBlockForTransforms(el, effectiveIterations === Number.POSITIVE_INFINITY);
241 resetCssAnimation(el);
242
243 el.style.animationName = animationName;
244 el.style.animationDuration = `${effectiveDuration}ms`;
245 el.style.animationTimingFunction = preset.timing;
246 el.style.animationIterationCount = effectiveIterations === Number.POSITIVE_INFINITY
247 ? "infinite"
248 : String(effectiveIterations);
249 el.style.animationFillMode = "forwards";
250
251 const runs = Number.parseInt(el.dataset.voltShiftRuns ?? "0", 10) + 1;
252 el.dataset.voltShiftRuns = String(runs);
253
254 if (effectiveIterations !== Number.POSITIVE_INFINITY) {
255 const totalDuration = effectiveDuration * effectiveIterations;
256 setTimeout(() => {
257 if (el.style.animationName === animationName) {
258 stopAnimation(el);
259 }
260 }, totalDuration);
261 }
262}
263
264function resetCssAnimation(el: HTMLElement): void {
265 const previousName = el.style.animationName;
266 if (!previousName) {
267 return;
268 }
269 el.style.animation = "none";
270 void el.offsetWidth;
271 el.style.animation = "";
272 el.style.animationName = "";
273}
274
275function ensureKeyframeSheet(): Optional<CSSStyleSheet> {
276 if (keyframeSheet) {
277 return keyframeSheet;
278 }
279
280 if (typeof document === "undefined" || !document.head) {
281 return undefined;
282 }
283
284 const styleEl = document.createElement("style");
285 styleEl.dataset.voltShift = "true";
286 document.head.append(styleEl);
287 keyframeSheet = styleEl.sheet ?? undefined;
288 return keyframeSheet;
289}
290
291function toCssProperty(property: string): string {
292 return property.replaceAll(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
293}
294
295function getOrCreateKeyframes(preset: AnimationPreset): Optional<string> {
296 const key = JSON.stringify(preset.keyframes) + preset.timing;
297 if (keyframeRegistry.has(key)) {
298 return keyframeRegistry.get(key);
299 }
300
301 const sheet = ensureKeyframeSheet();
302 if (!sheet) {
303 return undefined;
304 }
305
306 const animationName = `volt-shift-${keyframeCounter += 1}`;
307 keyframeRegistry.set(key, animationName);
308
309 const frames = preset.keyframes.map((frame, index) => {
310 const offset = frame.offset ?? (preset.keyframes.length > 1 ? index / (preset.keyframes.length - 1) : 0);
311 const percent = Math.round(offset * 10_000) / 100;
312 const declarations = Object.entries(frame).filter(([prop]) => prop !== "offset").map(([prop, value]) =>
313 `${toCssProperty(prop)}: ${value};`
314 ).join(" ");
315 return `${percent}% { ${declarations} }`;
316 }).join(" ");
317
318 sheet.insertRule(`@keyframes ${animationName} { ${frames} }`, sheet.cssRules.length);
319 return animationName;
320}
321
322function ensureInlineBlockForTransforms(el: HTMLElement, isInf: boolean): void {
323 if (el.dataset.voltShiftDisplayManaged) {
324 return;
325 }
326
327 if (typeof getComputedStyle !== "function") {
328 return;
329 }
330
331 if (!el.isConnected) {
332 return;
333 }
334
335 void el.offsetHeight;
336
337 const computedDisplay = getComputedStyle(el).display;
338 if (computedDisplay !== "inline") {
339 return;
340 }
341
342 el.dataset.voltShiftDisplayManaged = isInf ? "infinite" : "managed";
343 el.dataset.voltShiftOriginalDisplay = el.style.display ?? "";
344
345 if (!el.dataset.voltShiftOriginalTransformOrigin) {
346 el.dataset.voltShiftOriginalTransformOrigin = el.style.transformOrigin ?? "";
347 }
348
349 el.style.display = "inline-block";
350 if (!el.style.transformOrigin) {
351 el.style.transformOrigin = "center center";
352 }
353}
354
355function restoreOriginalDisplay(element: HTMLElement): void {
356 const state = element.dataset.voltShiftDisplayManaged;
357 if (!state || state === "infinite") {
358 return;
359 }
360
361 const original = element.dataset.voltShiftOriginalDisplay ?? "";
362 element.style.display = original;
363 const originalOrigin = element.dataset.voltShiftOriginalTransformOrigin ?? "";
364 element.style.transformOrigin = originalOrigin;
365 delete element.dataset.voltShiftDisplayManaged;
366 delete element.dataset.voltShiftOriginalDisplay;
367 delete element.dataset.voltShiftOriginalTransformOrigin;
368}
369
370/**
371 * Shift plugin handler.
372 * Provides CSS keyframe animations for elements.
373 *
374 * Syntax:
375 * - data-volt-shift="animationName" - Run animation with default settings
376 * - data-volt-shift="animationName.duration.iterations" - Custom duration and iterations
377 * - data-volt-shift="signalPath:animationName" - Watch signal to trigger animation
378 *
379 * @example
380 * ```html
381 * <!-- One-time animation on mount -->
382 * <button data-volt-shift="bounce">Click Me</button>
383 *
384 * <!-- Continuous pulse animation -->
385 * <div data-volt-shift="pulse">Loading...</div>
386 *
387 * <!-- Trigger animation based on signal -->
388 * <div data-volt-shift="error:shake">Error occurred!</div>
389 *
390 * <!-- Custom duration and iterations -->
391 * <div data-volt-shift="bounce.1000.3">Triple bounce!</div>
392 * ```
393 */
394export function shiftPlugin(ctx: PluginContext, value: string): void {
395 const el = ctx.element as HTMLElement;
396
397 const parsed = parseShiftValue(value);
398 if (!parsed) {
399 console.error(`[Volt] Invalid shift value: "${value}"`);
400 return;
401 }
402
403 const preset = getAnimation(parsed.animationName);
404 if (!preset) {
405 console.error(`[Volt] Unknown animation preset: "${parsed.animationName}"`);
406 return;
407 }
408
409 if (parsed.signalPath) {
410 const signal = ctx.findSignal(parsed.signalPath) as Optional<Signal<unknown>>;
411 if (!signal) {
412 console.error(`[Volt] Signal "${parsed.signalPath}" not found for shift binding`);
413 return;
414 }
415
416 const effectiveIterations = parsed.iterations ?? preset.iterations;
417 const isInfinite = effectiveIterations === Number.POSITIVE_INFINITY;
418 let previousValue = signal.get();
419
420 const unsubscribe = signal.subscribe((value) => {
421 if (value !== previousValue) {
422 if (value) {
423 applyAnimation(el, preset, parsed.duration, parsed.iterations);
424 } else if (isInfinite && el.style.animationName) {
425 stopAnimation(el);
426 }
427 }
428 previousValue = value;
429 });
430
431 ctx.addCleanup(unsubscribe);
432
433 if (signal.get()) {
434 ctx.lifecycle.onMount(() => {
435 requestAnimationFrame(() => {
436 applyAnimation(el, preset, parsed.duration, parsed.iterations);
437 });
438 });
439 }
440 } else {
441 ctx.lifecycle.onMount(() => {
442 requestAnimationFrame(() => {
443 applyAnimation(el, preset, parsed.duration, parsed.iterations);
444 });
445 });
446 }
447}