react-morphcard 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Łukasz Piera
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 ADDED
@@ -0,0 +1,100 @@
1
+ # react-morphcard
2
+
3
+ A React hook for a card that grows into a full detail screen and shrinks back. The card's title, name and badge fly into the header, the content settles in, and Back plays a shorter version that ends on the card.
4
+
5
+ <p align="center">
6
+ <img src="docs-site/public/videos/demo.gif" width="320" alt="A delivery card growing into its detail screen and shrinking back, at half speed">
7
+ </p>
8
+
9
+ **Docs and live demo: [morphcard.lucaspiera.com](https://morphcard.lucaspiera.com)**
10
+
11
+ - One hook, `useMorph`. No components, no context provider, no dependencies besides React 18 or 19.
12
+ - Works in any modern browser, in installed PWAs and in WebViews. It does not work in React Native, because it animates DOM elements with the Web Animations API.
13
+ - The detail screen opens by `clip-path` from the card's rectangle, so nothing stretches and `position: fixed` children stay put.
14
+ - Text that wraps differently on the card and in the header crossfades while it flies instead of scaling.
15
+ - Closing (300 ms) is quicker than opening (400 ms) and ends on the card.
16
+ - Closing during an open reverses the running animations from where they are.
17
+ - No flight when the card is off screen or the destination is missing. The sheet fades instead.
18
+ - Reduced motion is opacity only. Focus moves into the sheet and back to the card.
19
+ - Safe to import during server rendering. The built file starts with `"use client"` for the Next.js App Router.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pnpm add react-morphcard
25
+ # or
26
+ npm install react-morphcard
27
+ ```
28
+
29
+ ## Use
30
+
31
+ ```tsx
32
+ import { useMorph } from "react-morphcard";
33
+
34
+ export function Deliveries({ items }: { items: Delivery[] }) {
35
+ const morph = useMorph<Delivery>();
36
+ const d = morph.item;
37
+
38
+ return (
39
+ <>
40
+ <main ref={morph.backgroundRef}>
41
+ {items.map((item) => (
42
+ <article key={item.id} ref={morph.cardRef(item.id)} className="card">
43
+ <button
44
+ aria-label={`Open delivery ${item.id}`}
45
+ onClick={() => morph.open({ key: item.id, item })}
46
+ />
47
+ <h3 data-morph="title">{item.title}</h3>
48
+ <span data-morph="badge">{item.status}</span>
49
+ </article>
50
+ ))}
51
+ </main>
52
+ <div ref={morph.scrimRef} className="scrim" data-morph-close hidden />
53
+ <section ref={morph.sheetRef} className="sheet" role="dialog" aria-label="Delivery" hidden>
54
+ <button data-morph-close data-morph-focus>Back</button>
55
+ {d && (
56
+ <>
57
+ <h1 data-morph="title">{d.title}</h1>
58
+ <span data-morph="badge">{d.status}</span>
59
+ <div data-morph-stagger>{d.cargo}</div>
60
+ </>
61
+ )}
62
+ </section>
63
+ </>
64
+ );
65
+ }
66
+ ```
67
+
68
+ ```css
69
+ .sheet, .scrim { position: fixed; inset: 0; }
70
+ [hidden] { display: none !important; }
71
+ ```
72
+
73
+ Elements with the same `data-morph` key on the card and in the sheet fly between each other. `morph.item` is set before anything is measured and cleared only after the sheet has closed, so the sheet keeps its content for the whole flight back. If you keep the selected item in your own state, pass an update instead: `morph.open(event.currentTarget, () => setItem(item))`.
74
+
75
+ See [Getting started](https://morphcard.lucaspiera.com/docs/getting-started) and the [API reference](https://morphcard.lucaspiera.com/docs/api).
76
+
77
+ ## Develop
78
+
79
+ ```bash
80
+ pnpm install
81
+ pnpm build # dist/ with tsdown
82
+ pnpm typecheck
83
+ pnpm test # builds dist, then unit tests (vitest), including an import of dist in plain Node
84
+ pnpm test:e2e # browser tests (Playwright, Chromium, desktop and phone)
85
+ pnpm serve # examples at http://127.0.0.1:3301/examples/index.html
86
+ ```
87
+
88
+ The hook drives an internal engine in `src/morph.ts` that has no React in it. The package does not export it. `tsdown.fixtures.config.ts` builds it to `examples/dist/engine.js` for the plain JavaScript demo in `examples/` and for the engine tests.
89
+
90
+ The browser tests run at 1280×800 and 390×844. The engine tests cover open and close end states, reversing mid-transition, rapid clicks, reduced motion, missing and off-screen targets, scroll restore, focus return and leftover DOM. The React tests use `tests/fixtures/react` in StrictMode. They check where the card lands, how long `item` lives, unmounting mid-animation, live option changes and keyed cards. `scripts/record.mjs` records a transition frame by frame and `scripts/make-videos.sh` builds the videos used by the docs.
91
+
92
+ The docs site lives in `docs-site/` (Fumadocs, static export).
93
+
94
+ ## Publishing
95
+
96
+ `package.json` is ready for npm (`exports`, types, `files`; `prepublishOnly` runs the typecheck, the build and the unit tests). Publishing is one command: `pnpm publish`.
97
+
98
+ ## License
99
+
100
+ MIT © Łukasz Piera
@@ -0,0 +1,182 @@
1
+
2
+ //#region src/dom.d.ts
3
+ type Scroller = HTMLElement | Window;
4
+ //#endregion
5
+ //#region src/types.d.ts
6
+ type MorphState = "closed" | "opening" | "open" | "closing";
7
+ /** Why a shared element (or the whole card) did not fly. */
8
+ type SkipReason = "reduced-motion" | "no-card" | "card-offscreen" | "sheet-hidden" | "not-shared" | "missing-card-element" | "missing-sheet-element" | "sheet-element-offscreen";
9
+ interface PairReport {
10
+ key: string;
11
+ /** scale: one copy flies and scales. crossfade: both copies fly and swap. skip: no flight. */
12
+ mode: "scale" | "crossfade" | "skip";
13
+ reason?: SkipReason;
14
+ }
15
+ /** What the last transition decided. Handy when a flight did not happen. */
16
+ interface MorphPlan {
17
+ direction: "open" | "close";
18
+ /** morph: the surface grows from the card. fade: opacity only, no flight. */
19
+ choreography: "morph" | "fade";
20
+ reason?: SkipReason;
21
+ reduced: boolean;
22
+ backgroundScaled: boolean;
23
+ pairs: PairReport[];
24
+ }
25
+ interface MorphTiming {
26
+ /** Milliseconds. Closing is shorter: the user already knows where they are going back to. */
27
+ duration: {
28
+ open: number;
29
+ close: number;
30
+ };
31
+ /** surface: clip-path, shared flights and background. content: stagger and dock. */
32
+ easing: {
33
+ surface: string;
34
+ content: string;
35
+ };
36
+ /** Delay between staggered content blocks, in milliseconds. */
37
+ stagger: number;
38
+ /** Scale of the background while the sheet is open. false keeps it still. */
39
+ backgroundScale: number | false;
40
+ /** "system" follows prefers-reduced-motion; true or false forces it. */
41
+ reducedMotion: "system" | boolean;
42
+ /** Multiplies every duration and delay. 4 plays everything four times slower. */
43
+ timeScale: number;
44
+ closeOnEscape: boolean;
45
+ /** Put the list's scroll position back before measuring the card on close. */
46
+ restoreScroll: boolean;
47
+ }
48
+ interface MorphOptions extends Partial<Omit<MorphTiming, "duration" | "easing">> {
49
+ /** The detail surface. Positioned (fixed or absolute) and hidden while closed. */
50
+ sheet: HTMLElement;
51
+ /** What recedes behind the sheet. Must not contain the sheet. */
52
+ background?: HTMLElement | null;
53
+ /** Dims the background. Shown while the sheet is visible. */
54
+ scrim?: HTMLElement | null;
55
+ duration?: Partial<MorphTiming["duration"]>;
56
+ easing?: Partial<MorphTiming["easing"]>;
57
+ /** Keys allowed to fly. Default: every data-morph key. Others fade in place. */
58
+ shared?: string[];
59
+ /** Corner radius of the card in px. Default: the card's computed border radius. */
60
+ radius?: number;
61
+ /** Scroll container of the list. Default: nearest scrolling ancestor of the card, or the window. */
62
+ scroller?: Scroller;
63
+ /** Fill the sheet for this card. Runs before anything is measured; may return a promise. */
64
+ prepare?: (card: HTMLElement | null) => void | Promise<void>;
65
+ onStateChange?: (state: MorphState, card: HTMLElement | null) => void;
66
+ /**
67
+ * Where a close without `to` lands. Receives the card the sheet opened
68
+ * from. Lets a caller follow a card that was re-rendered while the sheet
69
+ * was open. Default: that same card.
70
+ */
71
+ resolveCard?: (card: HTMLElement | null) => HTMLElement | null;
72
+ }
73
+ interface CloseOptions {
74
+ /**
75
+ * The element to return to. Pass a new element when the list re-rendered,
76
+ * or null when there is nothing to return to (the sheet then fades out).
77
+ */
78
+ to?: HTMLElement | null;
79
+ }
80
+ interface Morph {
81
+ /** Opens the sheet from `card`. null opens without a flight (deep link). Resolves true once open. */
82
+ open(card?: HTMLElement | null): Promise<boolean>;
83
+ /** Closes back to the card. Resolves true once closed. */
84
+ close(options?: CloseOptions): Promise<boolean>;
85
+ readonly state: MorphState;
86
+ /** The card the sheet belongs to, or null. */
87
+ readonly card: HTMLElement | null;
88
+ /** The decision taken by the latest transition. */
89
+ readonly plan: MorphPlan | null;
90
+ /** Changes timing and callbacks for the next transition. */
91
+ setOptions(options: Partial<Omit<MorphOptions, "sheet" | "background" | "scrim">>): void;
92
+ /** Stops any transition, restores the page and removes listeners. */
93
+ destroy(): void;
94
+ }
95
+ //#endregion
96
+ //#region src/use-morph.d.ts
97
+ type UseMorphOptions = Omit<MorphOptions, "sheet" | "background" | "scrim" | "prepare" | "resolveCard">;
98
+ /** Identifies a card registered with `cardRef(key)`. */
99
+ type MorphKey = string | number;
100
+ /** The object form of `open`'s first argument. */
101
+ interface MorphTarget<T> {
102
+ /** A card registered with `cardRef(key)`. */
103
+ key?: MorphKey;
104
+ /** A card element. Used instead of the registered one when both are given. */
105
+ card?: HTMLElement | null;
106
+ /** Becomes `item` inside flushSync, before anything is measured. */
107
+ item?: T;
108
+ }
109
+ interface UseMorphCloseOptions {
110
+ /**
111
+ * The card to return to: an element, a key registered with `cardRef`, or
112
+ * null to fade out. Default: the card it opened from, or the element now
113
+ * registered under the same key if the list re-rendered.
114
+ */
115
+ to?: HTMLElement | MorphKey | null;
116
+ }
117
+ /** Read-only view of the engine, for debugging a transition. */
118
+ type MorphInstance = Pick<Morph, "state" | "card" | "plan">;
119
+ interface UseMorph<T = unknown> {
120
+ sheetRef: (el: HTMLElement | null) => void;
121
+ backgroundRef: (el: HTMLElement | null) => void;
122
+ scrimRef: (el: HTMLElement | null) => void;
123
+ /** A stable callback ref for each key. `open(key)` finds the card through it. */
124
+ cardRef: (key: MorphKey) => (el: HTMLElement | null) => void;
125
+ state: MorphState;
126
+ /**
127
+ * The item passed to the latest `open`. Set before measuring and cleared
128
+ * once the sheet has finished closing, so the sheet never empties mid-flight.
129
+ */
130
+ item: T | null;
131
+ /**
132
+ * Opens the sheet from a card element, a key registered with `cardRef`, or
133
+ * `{ key, card, item }`. null opens without a flight (deep link). `update`
134
+ * runs inside flushSync before anything is measured.
135
+ */
136
+ open(target: HTMLElement | MorphKey | MorphTarget<T> | null, update?: () => void): Promise<boolean>;
137
+ close(options?: UseMorphCloseOptions): Promise<boolean>;
138
+ /** The engine once the sheet is mounted, for reading `plan` and `card`. */
139
+ readonly instance: MorphInstance | null;
140
+ }
141
+ export declare function useMorph<T = unknown>(options?: UseMorphOptions): UseMorph<T>;
142
+ //#endregion
143
+ //#region src/timing.d.ts
144
+ export declare const defaults: MorphTiming;
145
+ /**
146
+ * Fractions of the open or close duration. With the defaults (400 / 300 ms):
147
+ * content enters at 130 ms, the card copy of a crossfading text is gone by
148
+ * 160 ms, and on close the big heading hands over to the card text by 90 ms.
149
+ */
150
+ export declare const choreography: {
151
+ readonly open: {
152
+ readonly contentDelay: 0.325;
153
+ readonly content: 0.65;
154
+ readonly copyOut: 0.4;
155
+ readonly targetIn: 0.5;
156
+ readonly restOut: 0.35;
157
+ readonly borderOut: 0.2;
158
+ readonly lateIn: 0.4;
159
+ readonly dockDelay: 0.4;
160
+ readonly dock: 0.75;
161
+ };
162
+ readonly close: {
163
+ readonly copyIn: 0.37;
164
+ readonly targetOut: 0.3;
165
+ readonly restDelay: 0.5;
166
+ readonly rest: 0.5;
167
+ readonly contentOut: 0.37;
168
+ readonly dock: 0.6;
169
+ };
170
+ /** Opacity only, in ms: the empty surface covers the page before any text appears. */
171
+ readonly fade: {
172
+ readonly surface: 120;
173
+ readonly contentDelay: 100;
174
+ readonly content: 140;
175
+ readonly closeContent: 100;
176
+ readonly closeSurfaceDelay: 100;
177
+ readonly closeSurface: 120;
178
+ };
179
+ };
180
+ //#endregion
181
+ export type { MorphInstance, MorphKey, MorphPlan, MorphState, MorphTarget, MorphTiming, PairReport, SkipReason, UseMorph, UseMorphCloseOptions, UseMorphOptions };
182
+ //# sourceMappingURL=index.d.ts.map