react-theme-tide 0.0.0-stage → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/options.d.ts +38 -0
- package/dist/options.js +53 -0
- package/dist/tide.d.ts +27 -0
- package/dist/tide.js +195 -0
- package/dist/use-theme-tide.d.ts +16 -0
- package/dist/use-theme-tide.js +18 -0
- package/dist/wave.d.ts +24 -0
- package/dist/wave.js +124 -0
- package/package.json +66 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alawonn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
#
|
|
1
|
+
# React Theme Tide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Switch your site between light and dark with a wave of water. The new theme rises from the bottom of the screen, pauses for a second with the waves still moving, then fills the page.
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<a href="https://github.com/alawonn/react-theme-tide/blob/main/docs/images/tide-1-rise.png"><img src="https://raw.githubusercontent.com/alawonn/react-theme-tide/main/docs/images/tide-1-rise.png" width="260" alt="The dark theme rising from the bottom of the demo page"></a>
|
|
7
|
+
<a href="https://github.com/alawonn/react-theme-tide/blob/main/docs/images/tide-2-pause.png"><img src="https://raw.githubusercontent.com/alawonn/react-theme-tide/main/docs/images/tide-2-pause.png" width="260" alt="The water pausing at 60% of the screen"></a>
|
|
8
|
+
<a href="https://github.com/alawonn/react-theme-tide/blob/main/docs/images/tide-3-fill.png"><img src="https://raw.githubusercontent.com/alawonn/react-theme-tide/main/docs/images/tide-3-fill.png" width="260" alt="The water about to fill the page"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
**[Try the demo](https://alawonn.github.io/react-theme-tide/)**
|
|
12
|
+
|
|
13
|
+
You keep your own theme code. React Theme Tide only animates the change.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install react-theme-tide
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pnpm add react-theme-tide
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
yarn add react-theme-tide
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun add react-theme-tide
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
deno add npm:react-theme-tide
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
You need React 18 or newer. There's no CSS to import.
|
|
38
|
+
|
|
39
|
+
## Use it
|
|
40
|
+
|
|
41
|
+
Wrap the code that changes your theme in `run`:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
"use client";
|
|
45
|
+
import { useThemeTide } from "react-theme-tide";
|
|
46
|
+
|
|
47
|
+
export function ThemeToggle() {
|
|
48
|
+
const tide = useThemeTide();
|
|
49
|
+
return (
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => tide.run(() => document.documentElement.classList.toggle("dark"))}
|
|
53
|
+
>
|
|
54
|
+
Switch theme
|
|
55
|
+
</button>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Already have a function that sets and saves the theme? Pass it in as it is:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
tide.run(() => setTheme("dark"));
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If your theme lives in React state, wrap the update in `flushSync` so the page changes before the water starts:
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
import { flushSync } from "react-dom";
|
|
70
|
+
|
|
71
|
+
tide.run(() => flushSync(() => setTheme("dark")));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It works in Next.js too. Use the hook in a Client Component.
|
|
75
|
+
|
|
76
|
+
People who prefer reduced motion get an instant change. So do browsers without View Transitions.
|
|
77
|
+
|
|
78
|
+
## Sites using it
|
|
79
|
+
|
|
80
|
+
| Mid-tide | Site |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| <a href="https://vertexx.fit"><img src="https://raw.githubusercontent.com/alawonn/react-theme-tide/main/docs/images/site-vertexx.png" width="320" alt="Vertexx switching from dark to light, paused partway up"></a> | [Vertexx](https://vertexx.fit) |
|
|
83
|
+
| <a href="https://meau.me"><img src="https://raw.githubusercontent.com/alawonn/react-theme-tide/main/docs/images/site-meau-me.png" width="320" alt="meau.me switching from light to dark, with long, slow waves"></a> | [meau.me](https://meau.me) |
|
|
84
|
+
| <a href="https://alawonn.com"><img src="https://raw.githubusercontent.com/alawonn/react-theme-tide/main/docs/images/site-alawonn.png" width="320" alt="alawonn.com switching from light to dark, with short, quick waves"></a> | [alawonn.com](https://alawonn.com) |
|
|
85
|
+
|
|
86
|
+
Using it on your site? [Open an issue](https://github.com/alawonn/react-theme-tide/issues/new?title=Add%20my%20site%20to%20the%20README&body=Site%20name%3A%0ASite%20URL%3A%0A) with the name and link, or send a pull request that adds it to this table.
|
|
87
|
+
|
|
88
|
+
## More
|
|
89
|
+
|
|
90
|
+
- [Usage and options](https://github.com/alawonn/react-theme-tide/blob/main/docs/usage.md): timing, waves, the API and Next.js setup.
|
|
91
|
+
- [How it works](https://github.com/alawonn/react-theme-tide/blob/main/docs/how-it-works.md): what happens on screen, edge cases and tested browsers.
|
|
92
|
+
- [Contributing](https://github.com/alawonn/react-theme-tide/blob/main/CONTRIBUTING.md)
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { themeTide, skipThemeTide, isThemeTideRunning, subscribeThemeTide, supportsThemeTide, type ThemeTideCommit, type ThemeTideResult, type ThemeTideInstantReason, } from "./tide.js";
|
|
2
|
+
export { resolveThemeTideOptions, themeTideDefaults, themeTideRanges, type ThemeTideOptions, type ResolvedThemeTideOptions, } from "./options.js";
|
|
3
|
+
export { useThemeTide, type UseThemeTide } from "./use-theme-tide.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface ThemeTideOptions {
|
|
2
|
+
/** Time for the water to rise from the bottom edge to the hold level, in ms. */
|
|
3
|
+
riseDuration?: number;
|
|
4
|
+
/** Time the mean waterline stays at the hold level, in ms. Waves keep moving. */
|
|
5
|
+
holdDuration?: number;
|
|
6
|
+
/** Time for the water to rise from the hold level past the top edge, in ms. */
|
|
7
|
+
finishDuration?: number;
|
|
8
|
+
/** How full the viewport is during the hold, from 0 (empty) to 1 (full). */
|
|
9
|
+
holdLevel?: number;
|
|
10
|
+
/** Height of a wave crest above the mean waterline, in CSS pixels. */
|
|
11
|
+
waveAmplitude?: number;
|
|
12
|
+
/** Distance between two crests, in CSS pixels. */
|
|
13
|
+
waveLength?: number;
|
|
14
|
+
/** Crests that pass a fixed point each second. */
|
|
15
|
+
waveSpeed?: number;
|
|
16
|
+
/** Change the theme instantly when the user prefers reduced motion. */
|
|
17
|
+
respectReducedMotion?: boolean;
|
|
18
|
+
/** Nonce for the temporary style element, for pages with a strict CSP. */
|
|
19
|
+
nonce?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ResolvedThemeTideOptions {
|
|
22
|
+
riseDuration: number;
|
|
23
|
+
holdDuration: number;
|
|
24
|
+
finishDuration: number;
|
|
25
|
+
holdLevel: number;
|
|
26
|
+
waveAmplitude: number;
|
|
27
|
+
waveLength: number;
|
|
28
|
+
waveSpeed: number;
|
|
29
|
+
respectReducedMotion: boolean;
|
|
30
|
+
nonce: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
type NumericKey = Exclude<keyof ResolvedThemeTideOptions, "respectReducedMotion" | "nonce">;
|
|
33
|
+
export declare const themeTideDefaults: Readonly<ResolvedThemeTideOptions>;
|
|
34
|
+
export declare const themeTideRanges: Readonly<Record<NumericKey, readonly [min: number, max: number]>>;
|
|
35
|
+
/** Fills in defaults and throws a RangeError or TypeError for bad values. */
|
|
36
|
+
export declare function resolveThemeTideOptions(options?: ThemeTideOptions): ResolvedThemeTideOptions;
|
|
37
|
+
export declare function totalDuration(options: ResolvedThemeTideOptions): number;
|
|
38
|
+
export {};
|
package/dist/options.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const themeTideDefaults = Object.freeze({
|
|
2
|
+
riseDuration: 900,
|
|
3
|
+
holdDuration: 1000,
|
|
4
|
+
finishDuration: 700,
|
|
5
|
+
holdLevel: 0.6,
|
|
6
|
+
waveAmplitude: 12,
|
|
7
|
+
waveLength: 240,
|
|
8
|
+
waveSpeed: 1,
|
|
9
|
+
respectReducedMotion: true,
|
|
10
|
+
nonce: undefined,
|
|
11
|
+
});
|
|
12
|
+
export const themeTideRanges = Object.freeze({
|
|
13
|
+
riseDuration: [0, 10000],
|
|
14
|
+
holdDuration: [0, 10000],
|
|
15
|
+
finishDuration: [0, 10000],
|
|
16
|
+
holdLevel: [0, 1],
|
|
17
|
+
waveAmplitude: [0, 80],
|
|
18
|
+
waveLength: [40, 4000],
|
|
19
|
+
waveSpeed: [0, 5],
|
|
20
|
+
});
|
|
21
|
+
/** Fills in defaults and throws a RangeError or TypeError for bad values. */
|
|
22
|
+
export function resolveThemeTideOptions(options = {}) {
|
|
23
|
+
const resolved = { ...themeTideDefaults };
|
|
24
|
+
for (const key of Object.keys(themeTideRanges)) {
|
|
25
|
+
const value = options[key];
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
continue;
|
|
28
|
+
const [min, max] = themeTideRanges[key];
|
|
29
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
30
|
+
throw new TypeError(`themeTide: ${key} must be a finite number.`);
|
|
31
|
+
}
|
|
32
|
+
if (value < min || value > max) {
|
|
33
|
+
throw new RangeError(`themeTide: ${key} must be between ${min} and ${max}, got ${value}.`);
|
|
34
|
+
}
|
|
35
|
+
resolved[key] = value;
|
|
36
|
+
}
|
|
37
|
+
if (options.respectReducedMotion !== undefined) {
|
|
38
|
+
if (typeof options.respectReducedMotion !== "boolean") {
|
|
39
|
+
throw new TypeError("themeTide: respectReducedMotion must be a boolean.");
|
|
40
|
+
}
|
|
41
|
+
resolved.respectReducedMotion = options.respectReducedMotion;
|
|
42
|
+
}
|
|
43
|
+
if (options.nonce !== undefined) {
|
|
44
|
+
if (typeof options.nonce !== "string") {
|
|
45
|
+
throw new TypeError("themeTide: nonce must be a string.");
|
|
46
|
+
}
|
|
47
|
+
resolved.nonce = options.nonce;
|
|
48
|
+
}
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
export function totalDuration(options) {
|
|
52
|
+
return options.riseDuration + options.holdDuration + options.finishDuration;
|
|
53
|
+
}
|
package/dist/tide.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ThemeTideOptions } from "./options.js";
|
|
2
|
+
/** Applies the new theme to the DOM. Called exactly once per themeTide call. */
|
|
3
|
+
export type ThemeTideCommit = () => void | Promise<void>;
|
|
4
|
+
export type ThemeTideInstantReason = "reduced-motion" | "unsupported" | "hidden" | "zero-duration" | "busy" | "interrupted";
|
|
5
|
+
export type ThemeTideResult = {
|
|
6
|
+
animated: true;
|
|
7
|
+
} | {
|
|
8
|
+
animated: false;
|
|
9
|
+
reason: ThemeTideInstantReason;
|
|
10
|
+
};
|
|
11
|
+
/** True while a tide animation is on screen. */
|
|
12
|
+
export declare function isThemeTideRunning(): boolean;
|
|
13
|
+
/** Calls `listener` when a tide starts or ends. Returns an unsubscribe function. */
|
|
14
|
+
export declare function subscribeThemeTide(listener: () => void): () => void;
|
|
15
|
+
/** Jumps a running tide to its end. The theme commit still happens once. */
|
|
16
|
+
export declare function skipThemeTide(): void;
|
|
17
|
+
/** True when this browser can animate a tide. Always false on the server. */
|
|
18
|
+
export declare function supportsThemeTide(): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Runs `commit` to change the theme and reveals the new theme with a rising
|
|
21
|
+
* water fill. Falls back to an instant change when the browser can't animate
|
|
22
|
+
* it, the user prefers reduced motion, or another tide is running.
|
|
23
|
+
*
|
|
24
|
+
* `commit` must update the DOM before it returns or before its promise
|
|
25
|
+
* resolves. With React state, wrap the update in `flushSync`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function themeTide(commit: ThemeTideCommit, options?: ThemeTideOptions): Promise<ThemeTideResult>;
|
package/dist/tide.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { resolveThemeTideOptions, totalDuration, } from "./options.js";
|
|
2
|
+
import { maskDeclarations, maskKeyframes, rushKeyframes, } from "./wave.js";
|
|
3
|
+
const attribute = "data-theme-tide";
|
|
4
|
+
const pseudo = "::view-transition-new(root)";
|
|
5
|
+
/** How long the water takes to reach the top after a scroll or pointer press. */
|
|
6
|
+
const rushDuration = 200;
|
|
7
|
+
let active = null;
|
|
8
|
+
// Commits run one after another, in call order, even when they are async.
|
|
9
|
+
let commitChain = Promise.resolve();
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
function setActive(next) {
|
|
12
|
+
active = next;
|
|
13
|
+
for (const listener of listeners)
|
|
14
|
+
listener();
|
|
15
|
+
}
|
|
16
|
+
/** True while a tide animation is on screen. */
|
|
17
|
+
export function isThemeTideRunning() {
|
|
18
|
+
return active !== null;
|
|
19
|
+
}
|
|
20
|
+
/** Calls `listener` when a tide starts or ends. Returns an unsubscribe function. */
|
|
21
|
+
export function subscribeThemeTide(listener) {
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(listener);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Jumps a running tide to its end. The theme commit still happens once. */
|
|
28
|
+
export function skipThemeTide() {
|
|
29
|
+
active?.transition.skipTransition();
|
|
30
|
+
}
|
|
31
|
+
/** True when this browser can animate a tide. Always false on the server. */
|
|
32
|
+
export function supportsThemeTide() {
|
|
33
|
+
return (typeof document !== "undefined" &&
|
|
34
|
+
typeof document.startViewTransition === "function" &&
|
|
35
|
+
typeof Element.prototype.animate === "function" &&
|
|
36
|
+
typeof CSS !== "undefined" &&
|
|
37
|
+
CSS.supports("mask-image", "linear-gradient(#000, #000)"));
|
|
38
|
+
}
|
|
39
|
+
function prefersReducedMotion() {
|
|
40
|
+
return (typeof matchMedia === "function" &&
|
|
41
|
+
matchMedia("(prefers-reduced-motion: reduce)").matches);
|
|
42
|
+
}
|
|
43
|
+
function once(commit) {
|
|
44
|
+
let result;
|
|
45
|
+
return () => {
|
|
46
|
+
if (!result) {
|
|
47
|
+
const run = commitChain.then(() => commit());
|
|
48
|
+
commitChain = run.catch(() => undefined);
|
|
49
|
+
result = run;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function instant(commit, reason) {
|
|
55
|
+
return commit().then(() => ({ animated: false, reason }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Runs `commit` to change the theme and reveals the new theme with a rising
|
|
59
|
+
* water fill. Falls back to an instant change when the browser can't animate
|
|
60
|
+
* it, the user prefers reduced motion, or another tide is running.
|
|
61
|
+
*
|
|
62
|
+
* `commit` must update the DOM before it returns or before its promise
|
|
63
|
+
* resolves. With React state, wrap the update in `flushSync`.
|
|
64
|
+
*/
|
|
65
|
+
export function themeTide(commit, options) {
|
|
66
|
+
const run = once(commit);
|
|
67
|
+
let resolved;
|
|
68
|
+
try {
|
|
69
|
+
resolved = resolveThemeTideOptions(options);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
// A bad option never blocks the theme change.
|
|
73
|
+
return run().then(() => Promise.reject(error));
|
|
74
|
+
}
|
|
75
|
+
if (!supportsThemeTide())
|
|
76
|
+
return instant(run, "unsupported");
|
|
77
|
+
if (active) {
|
|
78
|
+
// A second change during a tide finishes the first one, then applies
|
|
79
|
+
// the new theme without animation once the first commit has run.
|
|
80
|
+
const previous = active.transition.updateCallbackDone.catch(() => undefined);
|
|
81
|
+
skipThemeTide();
|
|
82
|
+
return previous.then(() => instant(run, "interrupted"));
|
|
83
|
+
}
|
|
84
|
+
if (document.activeViewTransition)
|
|
85
|
+
return instant(run, "busy");
|
|
86
|
+
if (resolved.respectReducedMotion && prefersReducedMotion()) {
|
|
87
|
+
return instant(run, "reduced-motion");
|
|
88
|
+
}
|
|
89
|
+
if (document.visibilityState === "hidden")
|
|
90
|
+
return instant(run, "hidden");
|
|
91
|
+
if (totalDuration(resolved) === 0)
|
|
92
|
+
return instant(run, "zero-duration");
|
|
93
|
+
return animate(run, resolved);
|
|
94
|
+
}
|
|
95
|
+
function viewportSize() {
|
|
96
|
+
return { width: window.innerWidth, height: window.innerHeight };
|
|
97
|
+
}
|
|
98
|
+
/** Size of the new-page snapshot, which can differ from innerHeight on mobile. */
|
|
99
|
+
function snapshotSize(fallback) {
|
|
100
|
+
const style = getComputedStyle(document.documentElement, pseudo);
|
|
101
|
+
const width = parseFloat(style.width);
|
|
102
|
+
const height = parseFloat(style.height);
|
|
103
|
+
return width > 0 && height > 0 ? { width, height } : fallback;
|
|
104
|
+
}
|
|
105
|
+
function rules(options, viewport) {
|
|
106
|
+
const root = `:root[${attribute}]`;
|
|
107
|
+
return [
|
|
108
|
+
`${root}::view-transition { pointer-events: none; }`,
|
|
109
|
+
`${root}::view-transition-old(root), ${root}${pseudo} { animation: none; mix-blend-mode: normal; }`,
|
|
110
|
+
`${root}${pseudo} { ${maskDeclarations(options, viewport)}; }`,
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
async function animate(run, options) {
|
|
114
|
+
const root = document.documentElement;
|
|
115
|
+
const style = document.createElement("style");
|
|
116
|
+
if (options.nonce)
|
|
117
|
+
style.nonce = options.nonce;
|
|
118
|
+
style.textContent = rules(options, viewportSize());
|
|
119
|
+
document.head.appendChild(style);
|
|
120
|
+
root.setAttribute(attribute, "");
|
|
121
|
+
let transition;
|
|
122
|
+
try {
|
|
123
|
+
transition = document.startViewTransition(run);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
style.remove();
|
|
127
|
+
root.removeAttribute(attribute);
|
|
128
|
+
return instant(run, "unsupported");
|
|
129
|
+
}
|
|
130
|
+
setActive({ transition });
|
|
131
|
+
let animation;
|
|
132
|
+
let viewport = viewportSize();
|
|
133
|
+
let interrupted = false;
|
|
134
|
+
const interrupt = () => {
|
|
135
|
+
interrupted = true;
|
|
136
|
+
transition.skipTransition();
|
|
137
|
+
};
|
|
138
|
+
// The old page is a still snapshot, so it can't scroll. On a scroll or a
|
|
139
|
+
// pointer press the water rushes to the top in rushDuration instead.
|
|
140
|
+
// Chromium sends pointer events to the root element during a transition,
|
|
141
|
+
// so controls can't receive them until the tide ends.
|
|
142
|
+
const rush = () => {
|
|
143
|
+
if (interrupted || !animation)
|
|
144
|
+
return;
|
|
145
|
+
const from = Number(animation.currentTime ?? 0);
|
|
146
|
+
if (totalDuration(options) - from <= rushDuration)
|
|
147
|
+
return;
|
|
148
|
+
interrupted = true;
|
|
149
|
+
const next = root.animate(rushKeyframes(options, viewport, from, rushDuration), {
|
|
150
|
+
duration: rushDuration,
|
|
151
|
+
easing: "linear",
|
|
152
|
+
fill: "forwards",
|
|
153
|
+
pseudoElement: pseudo,
|
|
154
|
+
});
|
|
155
|
+
animation.cancel();
|
|
156
|
+
animation = next;
|
|
157
|
+
};
|
|
158
|
+
const ready = transition.ready.then(() => {
|
|
159
|
+
viewport = snapshotSize(viewport);
|
|
160
|
+
animation = root.animate(maskKeyframes(options, viewport), {
|
|
161
|
+
duration: totalDuration(options),
|
|
162
|
+
easing: "linear",
|
|
163
|
+
fill: "forwards",
|
|
164
|
+
pseudoElement: pseudo,
|
|
165
|
+
});
|
|
166
|
+
window.addEventListener("scroll", rush, { passive: true });
|
|
167
|
+
window.addEventListener("pointerdown", rush, { capture: true });
|
|
168
|
+
// A resize changes the snapshot's size, so end the tide right away.
|
|
169
|
+
window.addEventListener("resize", interrupt);
|
|
170
|
+
}, () => undefined);
|
|
171
|
+
let failure;
|
|
172
|
+
try {
|
|
173
|
+
await transition.updateCallbackDone;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
failure = { error };
|
|
177
|
+
}
|
|
178
|
+
await ready;
|
|
179
|
+
await transition.finished.catch(() => undefined);
|
|
180
|
+
// A skipped transition still calls its update callback; `run` keeps this
|
|
181
|
+
// safe if a browser ever doesn't.
|
|
182
|
+
if (!failure)
|
|
183
|
+
await run().catch((error) => (failure = { error }));
|
|
184
|
+
const completed = !interrupted && animation?.playState === "finished";
|
|
185
|
+
window.removeEventListener("scroll", rush);
|
|
186
|
+
window.removeEventListener("pointerdown", rush, { capture: true });
|
|
187
|
+
window.removeEventListener("resize", interrupt);
|
|
188
|
+
animation?.cancel();
|
|
189
|
+
style.remove();
|
|
190
|
+
root.removeAttribute(attribute);
|
|
191
|
+
setActive(null);
|
|
192
|
+
if (failure)
|
|
193
|
+
throw failure.error;
|
|
194
|
+
return completed ? { animated: true } : { animated: false, reason: "interrupted" };
|
|
195
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ThemeTideOptions } from "./options.js";
|
|
2
|
+
import { type ThemeTideCommit, type ThemeTideResult } from "./tide.js";
|
|
3
|
+
export interface UseThemeTide {
|
|
4
|
+
/** Runs a tide around `commit`. Per-call options override the hook's options. */
|
|
5
|
+
run: (commit: ThemeTideCommit, options?: ThemeTideOptions) => Promise<ThemeTideResult>;
|
|
6
|
+
/** True while any tide on the page is animating. */
|
|
7
|
+
running: boolean;
|
|
8
|
+
/** Jumps a running tide to its end. */
|
|
9
|
+
skip: () => void;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* React access to themeTide. The hook keeps no theme state of its own: your
|
|
13
|
+
* commit callback decides the theme and where it's saved. Unmounting doesn't
|
|
14
|
+
* stop a tide that's already running, since the transition belongs to the page.
|
|
15
|
+
*/
|
|
16
|
+
export declare function useThemeTide(options?: ThemeTideOptions): UseThemeTide;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
3
|
+
import { isThemeTideRunning, skipThemeTide, subscribeThemeTide, themeTide, } from "./tide.js";
|
|
4
|
+
const serverRunning = () => false;
|
|
5
|
+
/**
|
|
6
|
+
* React access to themeTide. The hook keeps no theme state of its own: your
|
|
7
|
+
* commit callback decides the theme and where it's saved. Unmounting doesn't
|
|
8
|
+
* stop a tide that's already running, since the transition belongs to the page.
|
|
9
|
+
*/
|
|
10
|
+
export function useThemeTide(options) {
|
|
11
|
+
const running = useSyncExternalStore(subscribeThemeTide, isThemeTideRunning, serverRunning);
|
|
12
|
+
const optionsRef = useRef(options);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
optionsRef.current = options;
|
|
15
|
+
});
|
|
16
|
+
const run = useCallback((commit, overrides) => themeTide(commit, { ...optionsRef.current, ...overrides }), []);
|
|
17
|
+
return { run, running, skip: skipThemeTide };
|
|
18
|
+
}
|
package/dist/wave.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ResolvedThemeTideOptions } from "./options.js";
|
|
2
|
+
export interface Viewport {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
}
|
|
6
|
+
export interface WaveTile {
|
|
7
|
+
/** A CSS url() holding an SVG tile filled below one wavelength of the wave. */
|
|
8
|
+
image: string;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function waveTile(amplitude: number, length: number): WaveTile;
|
|
13
|
+
/** Mean waterline, in pixels from the top, at `time` ms into the tide. */
|
|
14
|
+
export declare function waterlineAt(time: number, options: ResolvedThemeTideOptions, viewportHeight: number): number;
|
|
15
|
+
/** Horizontal offset of the wave tile at `time` ms. Grows without wrapping. */
|
|
16
|
+
export declare function wavePhaseAt(time: number, options: ResolvedThemeTideOptions): number;
|
|
17
|
+
/** Static mask properties. The starting position hides the whole new page. */
|
|
18
|
+
export declare function maskDeclarations(options: ResolvedThemeTideOptions, viewport: Viewport): string;
|
|
19
|
+
export declare function maskKeyframes(options: ResolvedThemeTideOptions, viewport: Viewport): Keyframe[];
|
|
20
|
+
/**
|
|
21
|
+
* Keyframes that take the water from where it is at `from` ms to the end of
|
|
22
|
+
* the tide in `duration` ms. The waves keep their normal speed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function rushKeyframes(options: ResolvedThemeTideOptions, viewport: Viewport, from: number, duration: number): Keyframe[];
|
package/dist/wave.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { totalDuration } from "./options.js";
|
|
2
|
+
// All geometry is in CSS pixels of the viewport, with y measured down from
|
|
3
|
+
// the top edge. The waterline is the mean height of the wave; the wave phase
|
|
4
|
+
// only moves the crests sideways, so the level can hold while the water moves.
|
|
5
|
+
/** Keyframes per second. Positions between keyframes are interpolated linearly. */
|
|
6
|
+
const sampleRate = 60;
|
|
7
|
+
/** Points per wavelength in the SVG tile outline. */
|
|
8
|
+
const tileSegments = 32;
|
|
9
|
+
/**
|
|
10
|
+
* Transparent rows at the top and bottom of the tile. Chromium can wrap the
|
|
11
|
+
* tile vertically when it resamples at fractional device-pixel ratios; empty
|
|
12
|
+
* rows on both edges keep that from drawing a hairline above the crests.
|
|
13
|
+
*/
|
|
14
|
+
const tilePadding = 2;
|
|
15
|
+
function tileHeight(amplitude) {
|
|
16
|
+
return 2 * amplitude + 2 * tilePadding;
|
|
17
|
+
}
|
|
18
|
+
export function waveTile(amplitude, length) {
|
|
19
|
+
const height = tileHeight(amplitude);
|
|
20
|
+
const middle = amplitude + tilePadding;
|
|
21
|
+
const bottom = height - tilePadding;
|
|
22
|
+
let d = `M0 ${bottom}`;
|
|
23
|
+
for (let i = 0; i <= tileSegments; i++) {
|
|
24
|
+
const x = (i * length) / tileSegments;
|
|
25
|
+
const y = middle - amplitude * Math.sin((2 * Math.PI * x) / length);
|
|
26
|
+
d += ` L${round(x)} ${round(y)}`;
|
|
27
|
+
}
|
|
28
|
+
d += ` L${length} ${bottom} Z`;
|
|
29
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${length}" height="${height}"><path d="${d}"/></svg>`;
|
|
30
|
+
return {
|
|
31
|
+
image: `url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
|
|
32
|
+
width: length,
|
|
33
|
+
height,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function easeInOut(t) {
|
|
37
|
+
return -(Math.cos(Math.PI * t) - 1) / 2;
|
|
38
|
+
}
|
|
39
|
+
/** Mean waterline, in pixels from the top, at `time` ms into the tide. */
|
|
40
|
+
export function waterlineAt(time, options, viewportHeight) {
|
|
41
|
+
const { riseDuration, holdDuration, finishDuration, waveAmplitude } = options;
|
|
42
|
+
// Starts with the whole tile below the bottom edge and ends with every
|
|
43
|
+
// trough above the top edge. The extra 3 px at the end keep the corners
|
|
44
|
+
// covered on screens with fractional device pixels.
|
|
45
|
+
const start = viewportHeight + waveAmplitude + tilePadding;
|
|
46
|
+
const hold = viewportHeight * (1 - options.holdLevel);
|
|
47
|
+
const end = -(waveAmplitude + 3);
|
|
48
|
+
if (time <= 0)
|
|
49
|
+
return start;
|
|
50
|
+
if (time < riseDuration) {
|
|
51
|
+
return start + (hold - start) * easeInOut(time / riseDuration);
|
|
52
|
+
}
|
|
53
|
+
if (time <= riseDuration + holdDuration)
|
|
54
|
+
return hold;
|
|
55
|
+
const finishTime = time - riseDuration - holdDuration;
|
|
56
|
+
if (finishTime >= finishDuration)
|
|
57
|
+
return end;
|
|
58
|
+
return hold + (end - hold) * easeInOut(finishTime / finishDuration);
|
|
59
|
+
}
|
|
60
|
+
/** Horizontal offset of the wave tile at `time` ms. Grows without wrapping. */
|
|
61
|
+
export function wavePhaseAt(time, options) {
|
|
62
|
+
return (options.waveSpeed * options.waveLength * time) / 1000;
|
|
63
|
+
}
|
|
64
|
+
/** Height of the solid layer under the wave. Taller than any viewport position needs. */
|
|
65
|
+
function bodyHeight(options, viewport) {
|
|
66
|
+
return 2 * viewport.height + 4 * options.waveAmplitude + 8;
|
|
67
|
+
}
|
|
68
|
+
function maskPosition(time, options, viewport) {
|
|
69
|
+
return positionFor(waterlineAt(time, options, viewport.height), wavePhaseAt(time, options), options.waveAmplitude);
|
|
70
|
+
}
|
|
71
|
+
function positionFor(line, phase, amplitude) {
|
|
72
|
+
// The solid layer starts one pixel above the bottom of the tile's filled
|
|
73
|
+
// area and spills one pixel past each side, so no seam shows at fractional
|
|
74
|
+
// device pixels.
|
|
75
|
+
return `${round(phase)}px ${round(line - amplitude - tilePadding)}px, -1px ${round(line + amplitude - 1)}px`;
|
|
76
|
+
}
|
|
77
|
+
/** Static mask properties. The starting position hides the whole new page. */
|
|
78
|
+
export function maskDeclarations(options, viewport) {
|
|
79
|
+
const tile = waveTile(options.waveAmplitude, options.waveLength);
|
|
80
|
+
return [
|
|
81
|
+
`mask-image: ${tile.image}, linear-gradient(#000, #000)`,
|
|
82
|
+
"mask-repeat: repeat-x, no-repeat",
|
|
83
|
+
`mask-size: ${tile.width}px ${tile.height}px, calc(100% + 2px) ${bodyHeight(options, viewport)}px`,
|
|
84
|
+
`mask-position: ${maskPosition(0, options, viewport)}`,
|
|
85
|
+
"mask-mode: alpha",
|
|
86
|
+
"mask-clip: no-clip",
|
|
87
|
+
].join("; ");
|
|
88
|
+
}
|
|
89
|
+
export function maskKeyframes(options, viewport) {
|
|
90
|
+
const duration = totalDuration(options);
|
|
91
|
+
const steps = Math.max(1, Math.ceil((duration / 1000) * sampleRate));
|
|
92
|
+
// Phase boundaries are sampled exactly so the hold starts and ends on time.
|
|
93
|
+
const times = new Set([
|
|
94
|
+
options.riseDuration,
|
|
95
|
+
options.riseDuration + options.holdDuration,
|
|
96
|
+
]);
|
|
97
|
+
for (let i = 0; i <= steps; i++)
|
|
98
|
+
times.add((i / steps) * duration);
|
|
99
|
+
return [...times]
|
|
100
|
+
.sort((a, b) => a - b)
|
|
101
|
+
.map((time) => ({
|
|
102
|
+
offset: duration === 0 ? 0 : time / duration,
|
|
103
|
+
maskPosition: maskPosition(time, options, viewport),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Keyframes that take the water from where it is at `from` ms to the end of
|
|
108
|
+
* the tide in `duration` ms. The waves keep their normal speed.
|
|
109
|
+
*/
|
|
110
|
+
export function rushKeyframes(options, viewport, from, duration) {
|
|
111
|
+
const start = waterlineAt(from, options, viewport.height);
|
|
112
|
+
const end = waterlineAt(totalDuration(options), options, viewport.height);
|
|
113
|
+
const steps = Math.max(1, Math.ceil((duration / 1000) * sampleRate));
|
|
114
|
+
return Array.from({ length: steps + 1 }, (_, i) => {
|
|
115
|
+
const t = i / steps;
|
|
116
|
+
return {
|
|
117
|
+
offset: t,
|
|
118
|
+
maskPosition: positionFor(start + (end - start) * easeInOut(t), wavePhaseAt(from + t * duration, options), options.waveAmplitude),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function round(value) {
|
|
123
|
+
return Math.round(value * 100) / 100;
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-theme-tide",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Theme changes that rise up the page like water, using native View Transitions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/alawonn/react-theme-tide.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/alawonn/react-theme-tide#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/alawonn/react-theme-tide/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react",
|
|
18
|
+
"theme",
|
|
19
|
+
"dark-mode",
|
|
20
|
+
"view-transitions",
|
|
21
|
+
"animation"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"dist/*.js",
|
|
34
|
+
"dist/*.d.ts"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p demo/tsconfig.json --noEmit",
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:browser": "playwright test",
|
|
42
|
+
"demo": "vite --config demo/vite.config.ts",
|
|
43
|
+
"demo:build": "vite build --config demo/vite.config.ts",
|
|
44
|
+
"prepack": "npm run build"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^10.0.1",
|
|
51
|
+
"@playwright/test": "^1.64.1",
|
|
52
|
+
"@types/node": "^24.19.2",
|
|
53
|
+
"@types/pngjs": "^6.0.5",
|
|
54
|
+
"@types/react": "^19.3.0",
|
|
55
|
+
"@types/react-dom": "^19.3.0",
|
|
56
|
+
"@vitejs/plugin-react": "^6.2.1",
|
|
57
|
+
"eslint": "^10.14.0",
|
|
58
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
59
|
+
"jsdom": "^30.1.3",
|
|
60
|
+
"pngjs": "^7.0.0",
|
|
61
|
+
"react": "^19.3.1",
|
|
62
|
+
"react-dom": "^19.3.1",
|
|
63
|
+
"typescript": "~6.0.3",
|
|
64
|
+
"typescript-eslint": "^8.72.1",
|
|
65
|
+
"vite": "^8.3.5",
|
|
66
|
+
"vitest": "^5.1.0"
|
|
67
|
+
}
|
|
68
|
+
}
|