@pyreon/kinetic 0.21.0 → 0.23.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/README.md CHANGED
@@ -1,149 +1,131 @@
1
1
  # @pyreon/kinetic
2
2
 
3
- CSS-first animation library for Pyreon. Enter/exit transitions, staggered animations, height collapse, and list reconciliation — all in ~3KB gzipped.
3
+ CSS-transition animation library enter/exit, stagger, collapse, list reconciliation, ~3KB.
4
4
 
5
- ## Why Kinetic?
6
-
7
- Most animation libraries run their own JavaScript animation loop on the main thread. Kinetic takes a different approach: it delegates all interpolation to the browser's CSS transition engine (compositor thread for `transform`/`opacity`), and only handles orchestration — mount/unmount lifecycle, stagger timing, height measurement, and list diffing.
8
-
9
- The result: GPU-composited 60/120 FPS animations with a 3.2KB footprint.
10
-
11
- ### How It Compares
12
-
13
- | Library | Gzipped | Engine | Enter/Exit | Stagger | List Recon. | Collapse | Reduced Motion |
14
- | ---------------------- | ---------- | ------------------- | ---------- | ------- | ----------- | -------- | -------------- |
15
- | **@pyreon/kinetic** | **3.2 KB** | CSS transitions | Yes | Yes | Yes | Yes | Yes |
16
- | Motion (framer-motion) | ~34 KB | JS (rAF + WAAPI) | Yes | Yes | Yes | Quirky | Yes |
17
- | @react-spring/web | ~16-24 KB | JS (spring physics) | Yes | Partial | Yes | Manual | Yes |
18
- | react-transition-group | ~5 KB | CSS classes | Yes | No | Yes | No | No |
19
- | AutoAnimate | ~2.5 KB | JS (FLIP) | Yes | No | Yes | No | Yes |
20
-
21
- **Key advantages:**
22
-
23
- - **10x smaller than Motion** for CSS-transition use cases
24
- - **CSS-first**: `transform`/`opacity` run on GPU compositor thread, not main thread
25
- - **Only library** combining CSS transitions + stagger + collapse + list reconciliation
26
- - **122 presets** available via `@pyreon/kinetic-presets`
5
+ `@pyreon/kinetic` delegates interpolation to the browser's CSS transition engine (GPU compositor thread for `transform` / `opacity`) and only handles orchestration: mount/unmount lifecycle, stagger timing, height measurement, and key-based list diffing. The result is 60/120fps animations with a 3.2KB footprint and four composable modes (transition, collapse, stagger, group) accessed through a chainable, immutable API. Pair with `@pyreon/kinetic-presets` for 120+ ready-made animations, or define your own via inline `.enter()` / `.enterTo()` styles or class-based transitions (Tailwind, CSS modules). Reduced-motion is detected automatically; SSR renders children in their hidden-state class so scroll-reveal patterns reach SEO crawlers.
27
6
 
28
7
  ## Install
29
8
 
30
9
  ```bash
31
- bun add @pyreon/kinetic
10
+ bun add @pyreon/kinetic @pyreon/core @pyreon/reactivity @pyreon/runtime-dom
32
11
  ```
33
12
 
34
- ## Quick Start
13
+ ## Quick start
35
14
 
36
- ```ts
15
+ ```tsx
37
16
  import { kinetic, fade, slideUp } from '@pyreon/kinetic'
38
17
  import { signal } from '@pyreon/reactivity'
39
18
 
40
- // Create animated components at module level
41
19
  const FadeDiv = kinetic('div').preset(fade)
42
20
  const SlideSection = kinetic('section').preset(slideUp)
43
21
 
44
- // Use with signals for reactive show/hide
45
22
  const show = signal(true)
46
-
47
- FadeDiv({ show: show(), children: 'Hello, world!' })
23
+ <FadeDiv show={show()}>Hello, world!</FadeDiv>
48
24
  ```
49
25
 
50
- ## API
26
+ ## How it compares
51
27
 
52
- ### `kinetic(tag)`
28
+ | Library | Gzipped | Engine | Enter/Exit | Stagger | List Recon. | Collapse | Reduced Motion |
29
+ | ---------------------- | ---------- | ------------------- | ---------- | ------- | ----------- | -------- | -------------- |
30
+ | **@pyreon/kinetic** | **3.2 KB** | CSS transitions | Yes | Yes | Yes | Yes | Yes |
31
+ | Motion (framer-motion) | ~34 KB | JS (rAF + WAAPI) | Yes | Yes | Yes | Quirky | Yes |
32
+ | @react-spring/web | ~16-24 KB | JS (spring physics) | Yes | Partial | Yes | Manual | Yes |
33
+ | react-transition-group | ~5 KB | CSS classes | Yes | No | Yes | No | No |
34
+ | AutoAnimate | ~2.5 KB | JS (FLIP) | Yes | No | Yes | No | Yes |
35
+
36
+ Key advantages: 10x smaller than Motion for CSS-transition use cases; only library combining CSS transitions + stagger + collapse + key-based list reconciliation; 120+ presets via `@pyreon/kinetic-presets`.
53
37
 
54
- Creates an animated component. `tag` can be any HTML element string or Pyreon component.
38
+ ## `kinetic(tag)` animated component factory
55
39
 
56
40
  ```ts
57
- kinetic('div') // HTML element
58
- kinetic('section') // Any HTML tag
59
- kinetic(MyComponent) // Pyreon component
41
+ kinetic('div') // HTML element string
42
+ kinetic('section')
43
+ kinetic(MyComponent) // Any Pyreon component
60
44
  ```
61
45
 
62
- Returns a renderable Pyreon component with chain methods attached. Default mode: **transition**.
46
+ Returns a renderable Pyreon component with chain methods. Default mode: **transition**.
63
47
 
64
- ### Chain Methods
48
+ ## Chain methods
65
49
 
66
- All methods return a new component (immutable). The tag generic flows through, preserving HTML attribute types.
50
+ Every method returns a new component (immutable). The tag generic flows through, preserving HTML attribute types.
67
51
 
68
52
  ```ts
69
- // Style-based animation config
70
- .enter(styles) // CSSProperties applied at enter start
71
- .enterTo(styles) // CSSProperties applied after first frame
72
- .enterTransition(value) // CSS transition string for enter
73
- .leave(styles) // CSSProperties applied at leave start
74
- .leaveTo(styles) // CSSProperties applied after first frame
75
- .leaveTransition(value) // CSS transition string for leave
76
-
77
- // Class-based animation config
53
+ // Inline style-based config
54
+ .enter(styles) // CSSProperties at enter start
55
+ .enterTo(styles) // CSSProperties after first frame
56
+ .enterTransition(value) // CSS transition string
57
+ .leave(styles) // CSSProperties at leave start
58
+ .leaveTo(styles) // CSSProperties after first frame
59
+ .leaveTransition(value)
60
+
61
+ // Class-based config (Tailwind / CSS modules friendly)
78
62
  .enterClass({ active?, from?, to? })
79
63
  .leaveClass({ active?, from?, to? })
80
64
 
81
- // Apply a preset (spreads style + class props)
65
+ // Preset (spreads style + class props)
82
66
  .preset(preset)
83
67
 
84
- // Behavior config
85
- .config(opts) // appear, unmount, timeout (+ mode-specific)
86
- .on(callbacks) // onEnter, onAfterEnter, onLeave, onAfterLeave
68
+ // Behaviour
69
+ .config({ appear, unmount, timeout, ... })
70
+ .on({ onEnter, onAfterEnter, onLeave, onAfterLeave })
87
71
 
88
72
  // Mode switches
89
- .collapse(opts?) // Height animation mode
90
- .stagger(opts?) // Staggered children mode
73
+ .collapse(opts?) // Height-animation mode
74
+ .stagger(opts?) // Staggered-children mode
91
75
  .group() // Key-based list reconciliation mode
92
76
  ```
93
77
 
94
- ### Four Modes
78
+ ## Four modes
95
79
 
96
- #### Transition (default)
80
+ ### Transition (default)
97
81
 
98
- Single element enter/leave with CSS transitions.
82
+ Single-element enter/leave with CSS transitions.
99
83
 
100
- ```ts
84
+ ```tsx
101
85
  const FadeDiv = kinetic('div').preset(fade)
102
-
103
- FadeDiv({ show: isOpen, children: 'Content' })
86
+ <FadeDiv show={isOpen}>Content</FadeDiv>
104
87
  ```
105
88
 
106
- #### Collapse
89
+ ### Collapse
107
90
 
108
91
  Height animation with `overflow: hidden`. Measures `scrollHeight` automatically.
109
92
 
110
- ```ts
93
+ ```tsx
111
94
  const Accordion = kinetic('div').collapse()
112
95
  const FancyAccordion = kinetic('section').collapse({
113
96
  transition: 'height 400ms cubic-bezier(0.4, 0, 0.2, 1)',
114
97
  })
115
98
 
116
- Accordion({ show: isExpanded, children: 'Expandable content' })
99
+ <Accordion show={isExpanded}>Expandable content</Accordion>
117
100
  ```
118
101
 
119
- #### Stagger
102
+ ### Stagger
120
103
 
121
104
  Staggered entrance/exit for child elements.
122
105
 
123
- ```ts
106
+ ```tsx
124
107
  const StaggerList = kinetic('ul').preset(slideUp).stagger({ interval: 75 })
125
108
 
126
- StaggerList({
127
- show: isVisible,
128
- children: [
129
- h('li', { key: '1' }, 'Item 1'),
130
- h('li', { key: '2' }, 'Item 2'),
131
- h('li', { key: '3' }, 'Item 3'),
132
- ],
133
- })
109
+ <StaggerList show={isVisible}>
110
+ <li key="1">Item 1</li>
111
+ <li key="2">Item 2</li>
112
+ <li key="3">Item 3</li>
113
+ </StaggerList>
134
114
  ```
135
115
 
136
- #### Group
116
+ ### Group
137
117
 
138
- Key-based enter/exit — adding a child triggers enter animation, removing triggers leave + unmount. No `show` prop.
118
+ Key-based enter/exit — adding a keyed child triggers enter; removing triggers leave + unmount. No `show` prop.
139
119
 
140
- ```ts
120
+ ```tsx
141
121
  const AnimatedList = kinetic('ul').preset(fade).group()
142
122
 
143
- AnimatedList({ children: items.map((item) => h('li', { key: item.id }, item.text)) })
123
+ <AnimatedList>
124
+ {items.map((item) => <li key={item.id}>{item.text}</li>)}
125
+ </AnimatedList>
144
126
  ```
145
127
 
146
- ### Inline Configuration
128
+ ## Inline configuration
147
129
 
148
130
  Build animations without presets:
149
131
 
@@ -157,7 +139,7 @@ const SlidePanel = kinetic('aside')
157
139
  .leaveTransition('all 200ms ease-in')
158
140
  ```
159
141
 
160
- ### Class-Based Transitions
142
+ ## Class-based transitions
161
143
 
162
144
  Works with Tailwind CSS, CSS modules, or any class-based approach:
163
145
 
@@ -167,79 +149,85 @@ const TailwindFade = kinetic('div')
167
149
  .leaveClass({ active: 'transition-opacity duration-200', from: 'opacity-100', to: 'opacity-0' })
168
150
  ```
169
151
 
170
- ### Lifecycle Callbacks
152
+ ## Lifecycle callbacks
171
153
 
172
- ```ts
173
- FadeDiv({
174
- show: isOpen,
175
- onEnter: () => console.log('entering'),
176
- onAfterEnter: () => console.log('entered'),
177
- onLeave: () => console.log('leaving'),
178
- onAfterLeave: () => console.log('left'),
179
- children: 'Content',
180
- })
154
+ ```tsx
155
+ <FadeDiv
156
+ show={isOpen}
157
+ onEnter={() => console.log('entering')}
158
+ onAfterEnter={() => console.log('entered')}
159
+ onLeave={() => console.log('leaving')}
160
+ onAfterLeave={() => console.log('left')}
161
+ >
162
+ Content
163
+ </FadeDiv>
181
164
  ```
182
165
 
183
- ### Accessibility
166
+ ## Composition with rocketstyle
184
167
 
185
- Kinetic automatically detects `prefers-reduced-motion: reduce`. When enabled, animations are skipped instantly — callbacks still fire, but no visual animation occurs. No configuration needed.
168
+ Kinetic and rocketstyle compose naturally:
186
169
 
187
- ## Built-in Presets
170
+ ```ts
171
+ import rocketstyle from '@pyreon/rocketstyle'
188
172
 
189
- Six presets are included in the core package:
173
+ const Button = rocketstyle()({ component: 'button', name: 'Button' })
174
+ .theme({ primaryColor: 'blue' })
190
175
 
191
- ```ts
192
- import { fade, scaleIn, slideUp, slideDown, slideLeft, slideRight } from '@pyreon/kinetic'
176
+ const AnimatedButton = kinetic(Button).preset(fade)
177
+ // Has BOTH rocketstyle dimension props AND kinetic show/lifecycle props
178
+ <AnimatedButton show={isVisible} state="primary" size="large">Click me</AnimatedButton>
193
179
  ```
194
180
 
195
- For 122 presets, factories, and composition utilities, install `@pyreon/kinetic-presets`.
181
+ ## Built-in presets
196
182
 
197
- ## Composition with Rocketstyle
183
+ Six presets are included in the core package: `fade`, `scaleIn`, `slideUp`, `slideDown`, `slideLeft`, `slideRight`. For 120+ presets, factories, and composition utilities, add `@pyreon/kinetic-presets`.
198
184
 
199
- Kinetic and rocketstyle compose naturally:
185
+ ## Low-level hooks
186
+
187
+ If you need transition state outside `kinetic()`:
200
188
 
201
189
  ```ts
202
- import rocketstyle from '@pyreon/rocketstyle'
190
+ import { useTransitionState, useAnimationEnd } from '@pyreon/kinetic'
203
191
 
204
- const Button = rocketstyle()({ component: 'button', name: 'Button' }).theme({
205
- primaryColor: 'blue',
206
- })
192
+ const state = useTransitionState({ show: () => isOpen() })
193
+ // state.stage() → 'enter' | 'enter-active' | 'enter-to' | 'leave' | 'leave-active' | 'leave-to' | 'idle'
194
+ ```
207
195
 
208
- const AnimatedButton = kinetic(Button).preset(fade)
196
+ ## Accessibility
209
197
 
210
- // Has both rocketstyle props AND kinetic props
211
- AnimatedButton({ show: isVisible, primary: true, size: 'large', children: 'Click me' })
212
- ```
198
+ Kinetic automatically detects `prefers-reduced-motion: reduce`. When enabled, animations are skipped instantly — callbacks still fire, but no visual animation occurs. No configuration needed.
213
199
 
214
200
  ## SSR / SSG
215
201
 
216
202
  `<Transition show={() => false}>` **always renders children in SSR**, with the hidden-state class inlined (`leaveTo` if defined, else `enterFrom`). This matches Framer Motion / react-transition-group / react-spring conventions: content is structural, animation is visual.
217
203
 
218
- This is load-bearing for the scroll-reveal pattern on SSG sites — `useIntersection` can't fire on the server, so `show` is false at SSR. Without structural rendering, the wrapped content would be absent from prerendered HTML (bad for SEO, social scrapers, no-JS users).
204
+ Load-bearing for scroll-reveal on SSG sites — `useIntersection` can't fire on the server, so `show` is false at SSR. Without structural rendering, the wrapped content would be absent from prerendered HTML (bad for SEO, social scrapers, no-JS users).
219
205
 
220
206
  ```tsx
221
207
  const RevealSection = kinetic('section')
222
- .enter('transition-all duration-700')
223
- .enterFrom('opacity-0 translate-y-8') // ← this IS the SSR hidden state
224
- .enterTo('opacity-100 translate-y-0')
208
+ .enterClass({ active: 'transition-all duration-700', from: 'opacity-0 translate-y-8', to: 'opacity-100 translate-y-0' })
225
209
 
226
- // In your route show is driven by useIntersection on the client.
227
- // At SSR: <section class="opacity-0 translate-y-8">…full content here…</section>
228
- // On client: when scrolled into view, show flips true, enter animation runs.
210
+ // SSR: <section class="opacity-0 translate-y-8">…full content…</section>
211
+ // Client: when scrolled into view, show flips true → enter animation runs
229
212
  <RevealSection show={isInView}>
230
213
  <h2>Work Experience</h2>
231
214
  <p>…content reaches SEO crawlers and social scrapers…</p>
232
215
  </RevealSection>
233
216
  ```
234
217
 
235
- **Trade-off**: for an initially-hidden Transition, `unmount: true` (the default) no longer triggers a true DOM removal after a later leave animation completes — the element stays in DOM with the leave-to class applied. **Initially-visible** Transitions (`show: () => true` at setup) keep the runtime-unmount semantic unchanged. If you need true unmount on a started-hidden element, drive mount/unmount yourself outside `<Transition>`.
218
+ **Trade-off**: for an initially-hidden Transition, `unmount: true` (the default) no longer triggers a true DOM removal after a later leave animation completes — the element stays in DOM with the leave-to class applied. **Initially-visible** Transitions keep the runtime-unmount semantic unchanged. If you need true unmount on a started-hidden element, drive mount/unmount yourself outside `<Transition>`.
219
+
220
+ ## Gotchas
221
+
222
+ - **Animations run on the GPU compositor thread** only when you animate `transform` / `opacity` / `filter`. Animating `width` / `height` / `top` / `left` falls back to the main thread and may jank.
223
+ - **Stagger `interval` is per-CHILD**, not total duration. Five children at 75ms = 375ms total stagger window.
224
+ - **Group mode requires keyed children.** Without `key=`, every render replaces every child and you get no animation. The compiler-suggested `<For each={items} by={i => i.id}>` is the idiomatic pattern.
225
+ - **SSR initial-hidden Transitions break true-unmount semantics.** See SSR / SSG section above — opt out by driving mount/unmount yourself.
226
+ - **Reduced-motion skips visuals but still fires callbacks.** Don't rely on the animation completing for state machine progression — use callbacks.
236
227
 
237
- ## Peer Dependencies
228
+ ## Documentation
238
229
 
239
- | Package | Version |
240
- | ------------------ | -------- |
241
- | @pyreon/core | >= 0.0.1 |
242
- | @pyreon/reactivity | >= 0.0.1 |
230
+ Full docs: [docs.pyreon.dev/docs/kinetic](https://docs.pyreon.dev/docs/kinetic) (or `docs/docs/kinetic.md` in this repo).
243
231
 
244
232
  ## License
245
233
 
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Show, createRef, h, mergeProps, onMount, onUnmount, splitProps } from "@pyreon/core";
1
+ import { Show, createRef, cx, h, mergeProps, onMount, onUnmount, splitProps } from "@pyreon/core";
2
2
  import { runUntracked, signal, watch } from "@pyreon/reactivity";
3
3
  import { jsx } from "@pyreon/core/jsx-runtime";
4
4
 
@@ -172,16 +172,20 @@ const CollapseRenderer = ({ config, htmlProps, show, appear, timeout, transition
172
172
  ...stage() !== "entered" ? { overflow: "hidden" } : {},
173
173
  ...stage() === "hidden" ? { height: "0px" } : stage() === "entered" ? { height: "auto" } : {}
174
174
  };
175
- return h(config.tag, mergeProps(htmlProps, {
176
- ref: wrapperRef,
177
- style: wrapperStyle
178
- }), /* @__PURE__ */ jsx(Show, {
175
+ const innerContent = show() ? /* @__PURE__ */ jsx(Show, {
179
176
  when: shouldRender,
180
177
  children: /* @__PURE__ */ jsx("div", {
181
178
  ref: contentRef,
182
179
  children
183
180
  })
184
- }));
181
+ }) : /* @__PURE__ */ jsx("div", {
182
+ ref: contentRef,
183
+ children
184
+ });
185
+ return h(config.tag, mergeProps(htmlProps, {
186
+ ref: wrapperRef,
187
+ style: wrapperStyle
188
+ }), innerContent);
185
189
  };
186
190
 
187
191
  //#endregion
@@ -286,10 +290,39 @@ const cloneVNode = (vnode, extraProps) => ({
286
290
  ...extraProps
287
291
  }
288
292
  });
293
+ /**
294
+ * Resolves a `children` value the Pyreon compiler may have wrapped in a
295
+ * deferred accessor.
296
+ *
297
+ * **Why:** the compiler's prop-inlining pass rewrites `<Comp>{children}</Comp>`
298
+ * to `Comp({ ..., children: () => <inlined-expression> })` whenever
299
+ * `children` is a local `const` derived from a getter-shaped binding
300
+ * (`const children = childHolder.children` after `splitProps`). DOM-side
301
+ * consumers route through `mountChild` which already treats function
302
+ * children as reactive accessors, so the wrap is invisible there. Kinetic's
303
+ * Stagger/Group/Transition/Collapse renderers iterate `children` directly
304
+ * at the VNode level (to build per-child `TransitionItem`s), so a wrapped
305
+ * function landed in `Array.isArray(children) ? children : [children]` as
306
+ * `[function]` → `.filter(isVNode)` → `[]` → the rendered `<div>` had zero
307
+ * children → SSR content vanished post-hydration. Reproducer:
308
+ * `examples/bokisch.com`'s Intro section with `kinetic('div').stagger()`
309
+ * + `appear` + `show={() => true}` + component children → SSG HTML had
310
+ * `<h1>Hello</h1>`, post-hydrate the entire subtree was replaced by
311
+ * `<!--pyreon-->` markers.
312
+ *
313
+ * Kinetic deliberately snapshots children at render time (animation state
314
+ * is per-item, built once) — it does NOT observe children changes after
315
+ * the initial render. Eagerly unwrapping the function matches that
316
+ * contract; no reactivity is lost.
317
+ */
318
+ const resolveChildren = (children) => typeof children === "function" ? children() : children;
289
319
 
290
320
  //#endregion
291
321
  //#region src/kinetic/TransitionItem.tsx
292
322
  const applyEnter$1 = (el, config) => {
323
+ removeClasses(el, config.leave);
324
+ removeClasses(el, config.leaveFrom);
325
+ removeClasses(el, config.leaveTo);
293
326
  addClasses(el, config.enter);
294
327
  addClasses(el, config.enterFrom);
295
328
  if (config.enterStyle) Object.assign(el.style, config.enterStyle);
@@ -331,6 +364,7 @@ const applyReducedMotion$1 = (stage, callbacks, complete) => {
331
364
  * Uses cloneVNode to inject ref onto the child — the child must accept ref.
332
365
  */
333
366
  const TransitionItem = (props) => {
367
+ const child = resolveChildren(props.children);
334
368
  const appear = props.appear ?? false;
335
369
  const unmount = props.unmount ?? true;
336
370
  const timeout = props.timeout ?? 5e3;
@@ -340,7 +374,7 @@ const TransitionItem = (props) => {
340
374
  appear
341
375
  });
342
376
  const elementRef = createRef();
343
- const mergedRef = mergeRefs(elementRef, stateRef, props.children.props?.ref);
377
+ const mergedRef = mergeRefs(elementRef, stateRef, (child?.props)?.ref);
344
378
  const callbacks = {
345
379
  onEnter: props.onEnter,
346
380
  onAfterEnter: props.onAfterEnter,
@@ -393,14 +427,24 @@ const TransitionItem = (props) => {
393
427
  el.style.transition = "";
394
428
  }
395
429
  }, { immediate: true });
396
- return /* @__PURE__ */ jsx(Show, {
430
+ if (props.show()) return /* @__PURE__ */ jsx(Show, {
397
431
  when: shouldMount,
398
- fallback: unmount ? null : cloneVNode(props.children, {
432
+ fallback: unmount ? null : cloneVNode(child, {
399
433
  ref: mergedRef,
400
- style: mergeStyles(props.children.props?.style, { display: "none" })
434
+ style: mergeStyles((child?.props)?.style, { display: "none" })
401
435
  }),
402
- children: cloneVNode(props.children, { ref: mergedRef })
436
+ children: cloneVNode(child, { ref: mergedRef })
403
437
  });
438
+ const hiddenClass = props.leaveTo ?? props.enterFrom;
439
+ const hiddenStyle = props.leaveToStyle ?? props.enterStyle;
440
+ const childProps = child?.props ?? {};
441
+ const childClass = childProps.class;
442
+ const mergedClass = hiddenClass ? cx([childClass, hiddenClass]) : void 0;
443
+ const mergedStyle = mergeStyles(childProps.style, hiddenStyle);
444
+ const extra = { ref: mergedRef };
445
+ if (mergedClass !== void 0) extra.class = mergedClass;
446
+ if (mergedStyle !== void 0) extra.style = mergedStyle;
447
+ return cloneVNode(child, extra);
404
448
  };
405
449
 
406
450
  //#endregion
@@ -495,7 +539,8 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
495
539
  const effectiveTimeout = timeout ?? config.timeout ?? 5e3;
496
540
  const effectiveInterval = interval ?? config.interval ?? 50;
497
541
  const effectiveReverseLeave = reverseLeave ?? config.reverseLeave ?? false;
498
- const childArray = (Array.isArray(children) ? children : [children]).filter(isVNode);
542
+ const resolved = resolveChildren(children);
543
+ const childArray = (Array.isArray(resolved) ? resolved : [resolved]).filter(isVNode);
499
544
  const count = childArray.length;
500
545
  const staggeredChildren = childArray.map((child, index) => {
501
546
  const staggerIndex = !show() && effectiveReverseLeave ? count - 1 - index : index;
@@ -531,6 +576,9 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
531
576
  //#endregion
532
577
  //#region src/kinetic/TransitionRenderer.tsx
533
578
  const applyEnter = (el, config) => {
579
+ removeClasses(el, config.leave);
580
+ removeClasses(el, config.leaveFrom);
581
+ removeClasses(el, config.leaveTo);
534
582
  addClasses(el, config.enter);
535
583
  addClasses(el, config.enterFrom);
536
584
  if (config.enterStyle) Object.assign(el.style, config.enterStyle);
@@ -610,7 +658,7 @@ const TransitionRenderer = (props) => {
610
658
  el.style.transition = "";
611
659
  }
612
660
  }, { immediate: true });
613
- return /* @__PURE__ */ jsx(Show, {
661
+ if (props.show()) return /* @__PURE__ */ jsx(Show, {
614
662
  when: shouldMount,
615
663
  fallback: effectiveUnmount ? null : h(props.config.tag, mergeProps(props.htmlProps, {
616
664
  ref: mergedRef,
@@ -621,6 +669,18 @@ const TransitionRenderer = (props) => {
621
669
  }), props.children),
622
670
  children: h(props.config.tag, mergeProps(props.htmlProps, { ref: mergedRef }), props.children)
623
671
  });
672
+ const hiddenClass = props.config.leaveTo ?? props.config.enterFrom;
673
+ const hiddenStyle = props.config.leaveToStyle ?? props.config.enterStyle;
674
+ const childClass = props.htmlProps.class;
675
+ const mergedClass = hiddenClass ? cx([childClass, hiddenClass]) : void 0;
676
+ const mergedStyle = hiddenStyle ? {
677
+ ...props.htmlProps.style ?? {},
678
+ ...hiddenStyle
679
+ } : void 0;
680
+ const extra = { ref: mergedRef };
681
+ if (mergedClass !== void 0) extra.class = mergedClass;
682
+ if (mergedStyle !== void 0) extra.style = mergedStyle;
683
+ return h(props.config.tag, mergeProps(props.htmlProps, extra), props.children);
624
684
  };
625
685
 
626
686
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/kinetic",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "CSS-transition-based animation components for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,21 +42,21 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/core": "^0.21.0",
46
- "@pyreon/reactivity": "^0.21.0",
47
- "@pyreon/runtime-dom": "^0.21.0",
48
- "@pyreon/runtime-server": "^0.21.0",
49
- "@pyreon/test-utils": "^0.13.8",
50
- "@pyreon/typescript": "^0.21.0",
45
+ "@pyreon/core": "^0.23.0",
46
+ "@pyreon/reactivity": "^0.23.0",
47
+ "@pyreon/runtime-dom": "^0.23.0",
48
+ "@pyreon/runtime-server": "^0.23.0",
49
+ "@pyreon/test-utils": "^0.13.10",
50
+ "@pyreon/typescript": "^0.23.0",
51
51
  "@vitest/browser-playwright": "^4.1.4",
52
- "@vitus-labs/tools-rolldown": "^2.3.0"
52
+ "@vitus-labs/tools-rolldown": "^2.4.0"
53
53
  },
54
54
  "engines": {
55
55
  "node": ">= 22"
56
56
  },
57
57
  "dependencies": {
58
- "@pyreon/core": "^0.21.0",
59
- "@pyreon/reactivity": "^0.21.0",
60
- "@pyreon/runtime-dom": "^0.21.0"
58
+ "@pyreon/core": "^0.23.0",
59
+ "@pyreon/reactivity": "^0.23.0",
60
+ "@pyreon/runtime-dom": "^0.23.0"
61
61
  }
62
62
  }
package/src/Stagger.tsx CHANGED
@@ -2,7 +2,7 @@ import type { VNode } from '@pyreon/core'
2
2
  import { splitProps } from '@pyreon/core'
3
3
  import Transition from './Transition'
4
4
  import type { CSSProperties, StaggerProps } from './types'
5
- import { cloneVNode } from './utils'
5
+ import { cloneVNode, resolveChildren } from './utils'
6
6
 
7
7
  const isVNode = (child: unknown): child is VNode =>
8
8
  child != null && typeof child === 'object' && 'type' in (child as object)
@@ -22,7 +22,12 @@ const Stagger = (props: StaggerProps): VNode | null => {
22
22
  const appear = own.appear ?? false
23
23
  const timeout = own.timeout ?? 5000
24
24
 
25
- const childArray = (Array.isArray(own.children) ? own.children : [own.children]).filter(isVNode)
25
+ // Unwrap the compiler's `() => x` accessor wrap — see `resolveChildren`
26
+ // jsdoc. PR #731 fixed this on `StaggerRenderer` (the internal kinetic-
27
+ // mode renderer); this is the parallel fix for the top-level `<Stagger>`
28
+ // component, which has the same iteration shape and the same bug.
29
+ const resolved = resolveChildren(own.children)
30
+ const childArray = (Array.isArray(resolved) ? resolved : [resolved]).filter(isVNode)
26
31
  const count = childArray.length
27
32
 
28
33
  return (
@@ -5,7 +5,15 @@ import type { ClassTransitionProps, StyleTransitionProps, TransitionProps } from
5
5
  import useAnimationEnd from './useAnimationEnd'
6
6
  import { useReducedMotion } from './useReducedMotion'
7
7
  import useTransitionState from './useTransitionState'
8
- import { addClasses, cloneVNode, mergeRefs, mergeStyles, nextFrame, removeClasses } from './utils'
8
+ import {
9
+ addClasses,
10
+ cloneVNode,
11
+ mergeRefs,
12
+ mergeStyles,
13
+ nextFrame,
14
+ removeClasses,
15
+ resolveChildren,
16
+ } from './utils'
9
17
 
10
18
  const applyEnter = (
11
19
  el: HTMLElement,
@@ -109,8 +117,14 @@ const Transition = (props: TransitionProps): VNode | null => {
109
117
  appear,
110
118
  })
111
119
 
120
+ // Unwrap the compiler's `() => x` accessor wrap — see `resolveChildren`
121
+ // jsdoc. Parallel to `TransitionItem`'s fix (PR #731). Without this,
122
+ // `props.children.props` reads `function.props` (undefined), the merged
123
+ // ref is missing the child's own ref, and the downstream `cloneVNode`
124
+ // calls produce `{type: undefined}` → `<undefined>` DOM tags.
125
+ const child = resolveChildren(props.children) as VNode
112
126
  const elementRef = createRef<HTMLElement>()
113
- const childProps = (props.children.props ?? {}) as Record<string, unknown>
127
+ const childProps = (child?.props ?? {}) as Record<string, unknown>
114
128
  const mergedRef = mergeRefs(
115
129
  elementRef,
116
130
  stateRef,
@@ -198,7 +212,7 @@ const Transition = (props: TransitionProps): VNode | null => {
198
212
  fallback={
199
213
  unmount
200
214
  ? null
201
- : cloneVNode(props.children, {
215
+ : cloneVNode(child, {
202
216
  ref: mergedRef,
203
217
  style: mergeStyles(
204
218
  childProps.style as Record<string, string | number | undefined> | undefined,
@@ -207,7 +221,7 @@ const Transition = (props: TransitionProps): VNode | null => {
207
221
  })
208
222
  }
209
223
  >
210
- {cloneVNode(props.children, { ref: mergedRef })}
224
+ {cloneVNode(child, { ref: mergedRef })}
211
225
  </Show>
212
226
  )
213
227
  }
@@ -232,8 +246,18 @@ const Transition = (props: TransitionProps): VNode | null => {
232
246
  // The `watch(stage)` effect above drives the enter animation when
233
247
  // `show` flips true; `applyEnter` (above) clears these residual
234
248
  // hidden-state classes so they don't fight `enterTo`.
249
+ // Picker mirrors what #719 introduced for the kinetic(tag).<mode>
250
+ // renderers (TransitionRenderer / TransitionItem / CollapseRenderer):
251
+ // prefer leave-end state, fall back to pre-enter state. The
252
+ // `enterStyle` fallback covers the preset path — `@pyreon/kinetic-presets`
253
+ // factories (fadeUp, blurInUp, slideLeft, …) populate `enterStyle` as
254
+ // the hidden state but may not set `leaveToStyle`. Without this
255
+ // fallback, preset users SSR-render VISIBLE → flash-on-hydration.
256
+ // (PR #717 shipped this branch with `leaveToStyle` alone; the class
257
+ // picker already had the `enterFrom` fallback. This commit aligns the
258
+ // style picker so both halves match.)
235
259
  const hiddenClass = props.leaveTo ?? props.enterFrom
236
- const hiddenStyle = props.leaveToStyle
260
+ const hiddenStyle = props.leaveToStyle ?? props.enterStyle
237
261
  const childClass = childProps.class
238
262
  const mergedClass = hiddenClass
239
263
  ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
@@ -250,7 +274,7 @@ const Transition = (props: TransitionProps): VNode | null => {
250
274
  if (mergedClass !== undefined) extra.class = mergedClass
251
275
  if (mergedStyle !== undefined) extra.style = mergedStyle
252
276
 
253
- return cloneVNode(props.children, extra)
277
+ return cloneVNode(child, extra)
254
278
  }
255
279
 
256
280
  export default Transition