a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
1/**
2 * View Transitions API integration with CSS fallback
3 * Provides progressive enhancement for smooth DOM transitions
4 */
5
6import { prefersReducedMotion } from "$core/transitions";
7import type { Optional } from "$types/helpers";
8import type { ViewTransitionOptions as ViewTransitionOpts } from "$types/volt";
9
10type StartViewTransitionResult = {
11 finished: Promise<void>;
12 ready: Promise<void>;
13 updateCbDone: Promise<void>;
14 skipTransition(): void;
15};
16
17/**
18 * Extended Document type with View Transitions API support
19 */
20type DocumentWithViewTransition = Document & {
21 startViewTransition(updateCb: () => void | Promise<void>): StartViewTransitionResult;
22};
23
24/**
25 * Check if the browser supports the View Transitions API
26 *
27 * @returns true if document.startViewTransition is available
28 */
29export function supportsViewTransitions(): boolean {
30 return typeof document !== "undefined" && "startViewTransition" in document;
31}
32
33/**
34 * Execute a DOM update with View Transitions API.
35 * Falls back to direct execution if unsupported or reduced motion is preferred.
36 *
37 * @param cb - Function that performs DOM updates
38 * @param opts - Optional configuration for the transition
39 * @returns Promise that resolves when transition completes
40 *
41 * @example
42 * ```typescript
43 * // Simple transition
44 * await startViewTransition(() => {
45 * element.textContent = 'Updated!';
46 * });
47 *
48 * // Named transition for specific element
49 * await startViewTransition(() => {
50 * element.classList.add('active');
51 * }, { name: 'card-flip', elements: [element] });
52 * ```
53 */
54export async function startViewTransition(
55 cb: () => void | Promise<void>,
56 opts: ViewTransitionOpts = {},
57): Promise<void> {
58 const { respectReducedMotion = true, forceFallback = false } = opts;
59
60 if (respectReducedMotion && prefersReducedMotion()) {
61 await cb();
62 return;
63 }
64
65 if (!forceFallback && supportsViewTransitions()) {
66 const namedElements = applyViewTransitionNames(opts.name, opts.elements);
67
68 try {
69 const transition = (document as DocumentWithViewTransition).startViewTransition(cb);
70 await transition.finished;
71 } finally {
72 removeViewTransitionNames(namedElements);
73 }
74 } else {
75 await cb();
76 }
77}
78
79/**
80 * Execute a transition with a specific named view transition.
81 * This is a convenience wrapper around startViewTransition for named transitions.
82 *
83 * @param name - View transition name (maps to view-transition-name CSS property)
84 * @param elements - Elements to apply the named transition to
85 * @param cb - Function that performs DOM updates
86 * @returns Promise that resolves when transition completes
87 *
88 * @example
89 * ```typescript
90 * const card = document.querySelector('.card');
91 * await namedViewTransition('card-flip', [card], () => {
92 * card.classList.toggle('flipped');
93 * });
94 * ```
95 */
96export async function namedViewTransition(
97 name: string,
98 elements: HTMLElement[],
99 cb: () => void | Promise<void>,
100): Promise<void> {
101 return startViewTransition(cb, { name, elements });
102}
103
104/**
105 * Apply view-transition-name CSS property to elements.
106 * Returns a map of elements to their original view-transition-name values
107 * for later restoration.
108 *
109 * @param baseName - Base name for the transition (suffixed with index if multiple elements)
110 * @param elements - Elements to apply names to
111 * @returns Map of elements to their original view-transition-name values
112 *
113 * @internal
114 */
115function applyViewTransitionNames(
116 baseName: Optional<string>,
117 elements: Optional<HTMLElement[]>,
118): Map<HTMLElement, string> {
119 const originalNames = new Map<HTMLElement, string>();
120
121 if (!baseName || !elements || elements.length === 0) {
122 return originalNames;
123 }
124
125 for (const [index, element] of elements.entries()) {
126 const originalValue = element.style.viewTransitionName;
127 originalNames.set(element, originalValue);
128
129 const transitionName = elements.length === 1 ? baseName : `${baseName}-${index}`;
130 element.style.viewTransitionName = transitionName;
131 }
132
133 return originalNames;
134}
135
136/**
137 * Remove view-transition-name CSS properties and restore original values.
138 *
139 * @param namedElements - Map of elements to their original view-transition-name values
140 *
141 * @internal
142 */
143function removeViewTransitionNames(namedElements: Map<HTMLElement, string>): void {
144 for (const [element, originalValue] of namedElements) {
145 if (originalValue) {
146 element.style.viewTransitionName = originalValue;
147 } else {
148 element.style.viewTransitionName = "";
149 }
150 }
151}
152
153/**
154 * Wraps a callback with View Transitions API if supported.
155 * This is a simpler version without named transitions support.
156 *
157 * @param cb - Function to execute
158 * @param respectReducedMotion - Skip transition if prefers-reduced-motion
159 *
160 * @example
161 * ```typescript
162 * withViewTransition(() => {
163 * element.remove();
164 * });
165 * ```
166 */
167export function withViewTransition(cb: () => void, respectReducedMotion = true): void {
168 if (respectReducedMotion && prefersReducedMotion()) {
169 cb();
170 return;
171 }
172
173 if (supportsViewTransitions()) {
174 (document as DocumentWithViewTransition).startViewTransition(cb);
175 } else {
176 cb();
177 }
178}