@a4ui/core 0.29.1 → 0.30.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.
@@ -0,0 +1,37 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Fan shape applied when a {@link CardSpread} opens. Defaults to `'arc'`. */
3
+ export type CardSpreadLayout = 'arc' | 'long-arc' | 'linear' | 'corner' | 'cascade' | 'scatter' | 'wheel';
4
+ export interface CardSpreadProps {
5
+ /** Card contents; each entry becomes one card in the stack. */
6
+ items: JSX.Element[];
7
+ /** Fan shape. @default 'arc' */
8
+ layout?: CardSpreadLayout;
9
+ /** Controlled open state. Omit to spread on hover instead. */
10
+ open?: boolean;
11
+ /** Strength knob: degrees for arced/wheel layouts, px for linear/corner/cascade/scatter. Per-layout default applies when omitted. */
12
+ spread?: number;
13
+ class?: string;
14
+ 'aria-label'?: string;
15
+ }
16
+ /**
17
+ * A stack of cards that fans out into an arc, line, corner, cascade, scatter,
18
+ * or wheel shape. At rest the cards sit in a near-centered pile; opening
19
+ * (controlled via `open`, or on hover when uncontrolled) spreads them.
20
+ * Animates only `transform`/`opacity` (Motion spring) so the fan stays
21
+ * compositor-only, and falls back to a static fanned layout under reduced
22
+ * motion.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <CardSpread
27
+ * layout="arc"
28
+ * aria-label="Trip itinerary cards"
29
+ * items={[
30
+ * <div class="p-4">Day 1 — Arrival</div>,
31
+ * <div class="p-4">Day 2 — Old Town</div>,
32
+ * <div class="p-4">Day 3 — Coast drive</div>,
33
+ * ]}
34
+ * />
35
+ * ```
36
+ */
37
+ export declare function CardSpread(props: CardSpreadProps): JSX.Element;
@@ -0,0 +1,38 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Layout curve for a {@link Carousel3D}. Defaults to `'coverflow'`. */
3
+ export type Carousel3DVariant = 'coverflow' | 'arc';
4
+ export interface Carousel3DProps {
5
+ /** Slides shown around the active one, arranged by distance from it. */
6
+ slides: JSX.Element[];
7
+ /** Layout curve. @default 'coverflow' */
8
+ variant?: Carousel3DVariant;
9
+ /** Controlled active index. */
10
+ active?: number;
11
+ /** Initial active index when uncontrolled. @default 0 */
12
+ defaultActive?: number;
13
+ /** Fires with the new index whenever the active slide changes. */
14
+ onChange?: (index: number) => void;
15
+ class?: string;
16
+ }
17
+ /**
18
+ * A 3D carousel that arranges slides around the active one on a coverflow or
19
+ * shallow arc curve — `perspective` + `preserve-3d`, each slide positioned
20
+ * purely by `transform`/`opacity` (Motion spring) so it stays
21
+ * compositor-only. Supports controlled/uncontrolled `active`, prev/next
22
+ * buttons, dot indicators, arrow-key navigation, and pointer-drag swiping.
23
+ * Falls back to a flat opacity crossfade (no 3D transforms) under reduced
24
+ * motion.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <Carousel3D
29
+ * variant="coverflow"
30
+ * slides={[
31
+ * <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail A — Ridge Loop</div>,
32
+ * <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail B — Falls Overlook</div>,
33
+ * <div class="grid h-full place-items-center rounded-xl border border-border bg-card p-6 text-center">Trail C — Summit Pass</div>,
34
+ * ]}
35
+ * />
36
+ * ```
37
+ */
38
+ export declare function Carousel3D(props: Carousel3DProps): JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface FocusBlurGroupProps<T> {
3
+ /** The full data set to render. */
4
+ items: T[];
5
+ /** Row renderer. */
6
+ children: (item: T, index: number) => JSX.Element;
7
+ /** Blur (px) applied to non-focused items. @default 2 */
8
+ blur?: number;
9
+ /** Opacity (0..1) applied to non-focused items. @default 0.5 */
10
+ dim?: number;
11
+ /** Applied to the container — use it for layout (e.g. `flex gap-6`). */
12
+ class?: string;
13
+ }
14
+ /**
15
+ * Container that spotlights whichever item is hovered or keyboard-focused:
16
+ * that item stays sharp and fully opaque while its siblings blur and dim.
17
+ * Only `opacity`/`filter` transition — never a layout property — and under
18
+ * `motionReduced()` the blur is skipped (dim-only, no transition) since blur
19
+ * motion reads as motion-sickness-adjacent.
20
+ *
21
+ * @example
22
+ * ```tsx
23
+ * <FocusBlurGroup items={navLinks} class="flex gap-6">
24
+ * {(link) => (
25
+ * <a href={link.href} class="text-sm font-medium text-foreground">
26
+ * {link.label}
27
+ * </a>
28
+ * )}
29
+ * </FocusBlurGroup>
30
+ * ```
31
+ */
32
+ export declare function FocusBlurGroup<T>(props: FocusBlurGroupProps<T>): JSX.Element;
@@ -0,0 +1,50 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Visual style of an {@link IconMorphButton}. Defaults to `'ghost'`. */
3
+ export type IconMorphButtonVariant = 'solid' | 'ghost' | 'outline';
4
+ export interface IconMorphButtonProps {
5
+ /** Icon shown while `pressed` is `false`, e.g. `<Copy size={18} />`. */
6
+ inactive: JSX.Element;
7
+ /** Icon shown while `pressed` is `true`, e.g. `<Check size={18} />`. */
8
+ active: JSX.Element;
9
+ /** Controlled pressed state. Omit to let the button manage its own state. */
10
+ pressed?: boolean;
11
+ /** Initial pressed state when uncontrolled. Defaults to `false`. */
12
+ defaultPressed?: boolean;
13
+ /** Fired with the next pressed state, controlled or uncontrolled. */
14
+ onChange?: (pressed: boolean) => void;
15
+ /**
16
+ * When greater than `0`, auto-reverts to `inactive` after this many ms once
17
+ * pressed (e.g. copy → check → copy). The timer is cleared on cleanup and
18
+ * restarted on every re-press. @default 0
19
+ */
20
+ revertAfter?: number;
21
+ /** Optional label shown next to the icon while `pressed` is `false`. */
22
+ label?: JSX.Element;
23
+ /** Optional label shown next to the icon while `pressed` is `true`. */
24
+ activeLabel?: JSX.Element;
25
+ /** Visual style. Defaults to `'ghost'`. */
26
+ variant?: IconMorphButtonVariant;
27
+ class?: string;
28
+ 'aria-label'?: string;
29
+ }
30
+ /**
31
+ * Two-state icon toggle button whose icon morphs (spring scale + rotate +
32
+ * fade) between `inactive` and `active` glyphs, stacked in a fixed-size box.
33
+ * Supports controlled (`pressed`/`onChange`) or uncontrolled
34
+ * (`defaultPressed`) use, an optional auto-revert timer, and an optional
35
+ * crossfading label. Falls back to an instant swap under reduced motion.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * <IconMorphButton
40
+ * inactive={<Copy size={18} />}
41
+ * active={<Check size={18} />}
42
+ * aria-label="Copy to clipboard"
43
+ * revertAfter={1500}
44
+ * onChange={(pressed) => {
45
+ * if (pressed) void navigator.clipboard?.writeText('npm install @a4ui/core')
46
+ * }}
47
+ * />
48
+ * ```
49
+ */
50
+ export declare function IconMorphButton(props: IconMorphButtonProps): JSX.Element;
@@ -0,0 +1,35 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Which icon a {@link LikeButton} renders — picks the semantic use (like/favorite/save). */
3
+ export type LikeButtonIcon = 'heart' | 'star' | 'bookmark';
4
+ export interface LikeButtonProps {
5
+ /** Controlled pressed state. When set, wins over internal state — pair with `onChange`. */
6
+ pressed?: boolean;
7
+ /** Initial pressed state for uncontrolled use. @default false */
8
+ defaultPressed?: boolean;
9
+ onChange?: (pressed: boolean) => void;
10
+ /** Which icon to render. @default 'heart' */
11
+ icon?: LikeButtonIcon;
12
+ /** Count shown next to the icon (e.g. total likes). */
13
+ count?: number;
14
+ /** Show `count`. @default false */
15
+ showCount?: boolean;
16
+ /** Class applied to the icon/count when pressed. @default 'text-primary' */
17
+ activeClass?: string;
18
+ class?: string;
19
+ 'aria-label'?: string;
20
+ }
21
+ /**
22
+ * Like/favorite/save-later toggle button. One component covers all three via
23
+ * `icon` ('heart' | 'star' | 'bookmark') rather than three near-identical
24
+ * toggles. On becoming pressed the icon fills, morphs to `activeClass`,
25
+ * springs a scale pop, and bursts a handful of tiny icon copies outward —
26
+ * skipped under reduced motion, which just flips the filled/unfilled state
27
+ * instantly. Works controlled (`pressed` + `onChange`) or uncontrolled
28
+ * (`defaultPressed`).
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <LikeButton defaultPressed count={128} showCount aria-label="Like this post" />
33
+ * ```
34
+ */
35
+ export declare function LikeButton(props: LikeButtonProps): JSX.Element;
@@ -0,0 +1,32 @@
1
+ import { JSX } from 'solid-js';
2
+ /** Feedback micro-animation a {@link MicroButton} plays. Defaults to `'ring'`. */
3
+ export type MicroButtonEffect = 'spin' | 'shake' | 'pulse' | 'ring' | 'sparkle' | 'glare';
4
+ /** Visual style of a {@link MicroButton}. Defaults to `'solid'`. */
5
+ export type MicroButtonVariant = 'solid' | 'ghost' | 'outline';
6
+ export interface MicroButtonProps {
7
+ children?: JSX.Element;
8
+ /** Feedback micro-animation. `'glare'` plays on hover; the rest play on click. @default 'ring' */
9
+ effect?: MicroButtonEffect;
10
+ onClick?: (e: MouseEvent) => void;
11
+ /** Visual style. @default 'solid' */
12
+ variant?: MicroButtonVariant;
13
+ disabled?: boolean;
14
+ class?: string;
15
+ 'aria-label'?: string;
16
+ }
17
+ /**
18
+ * Small icon/label button that plays a short feedback micro-animation on
19
+ * interaction — spin, shake, pulse, ring, or sparkle on click, or a diagonal
20
+ * glare sweep on hover. Every effect animates only `transform`/`opacity`, so
21
+ * it stays cheap even on low-end devices; decorative particles are spawned
22
+ * and removed on completion, never left in the DOM. Renders a plain static
23
+ * `<button>` with no effect under reduced motion.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <MicroButton effect="sparkle" aria-label="Like">
28
+ * <Heart size={16} />
29
+ * </MicroButton>
30
+ * ```
31
+ */
32
+ export declare function MicroButton(props: MicroButtonProps): JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { JSX, ParentProps } from 'solid-js';
2
+ /** Direction the label/arrow slide toward on hover. Defaults to `'right'`. */
3
+ export type SlideArrowDirection = 'right' | 'left';
4
+ /** Visual style of a {@link SlideArrowButton}. Defaults to `'solid'`. */
5
+ export type SlideArrowButtonVariant = 'solid' | 'outline';
6
+ export interface SlideArrowButtonProps extends ParentProps {
7
+ onClick?: (e: MouseEvent) => void;
8
+ /** Direction the label/arrow slide toward on hover. Defaults to `'right'`. */
9
+ direction?: SlideArrowDirection;
10
+ /** Custom icon; defaults to `ArrowRight`/`ArrowLeft` per `direction`. */
11
+ icon?: JSX.Element;
12
+ /** Visual style. Defaults to `'solid'`. */
13
+ variant?: SlideArrowButtonVariant;
14
+ disabled?: boolean;
15
+ class?: string;
16
+ /** Accessible label, forwarded to the underlying `<button>`. */
17
+ 'aria-label'?: string;
18
+ }
19
+ /**
20
+ * CTA button: at rest the label sits centered; on hover it slides toward
21
+ * `direction` while an arrow slides in from the opposite edge. Pure CSS
22
+ * transform/opacity transitions — no JS animation engine.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * <SlideArrowButton onClick={() => go()}>Get started</SlideArrowButton>
27
+ * <SlideArrowButton direction="left" variant="outline">Back</SlideArrowButton>
28
+ * ```
29
+ */
30
+ export declare function SlideArrowButton(props: SlideArrowButtonProps): JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { JSX } from 'solid-js';
2
+ export interface TimeMachineStackProps {
3
+ /** Slide contents, one per stack position. */
4
+ slides: JSX.Element[];
5
+ /** Controlled active index. Omit to manage it internally. */
6
+ active?: number;
7
+ /** Initial active index when uncontrolled. @default 0 */
8
+ defaultActive?: number;
9
+ /** Fired whenever the active index changes, via click or keyboard. */
10
+ onChange?: (index: number) => void;
11
+ class?: string;
12
+ }
13
+ /**
14
+ * Apple-style "time machine" depth stack: the active slide is front and
15
+ * full-size, and the rest recede into the screen behind it — each further
16
+ * slide translated up, pushed back on Z, scaled down, and dimmed. A vertical
17
+ * scrubber of real buttons (one per slide), pinned to the right edge, jumps
18
+ * to any index; the stage handles ArrowUp/ArrowDown (and Left/Right) when
19
+ * focused. Animates only `transform`/`opacity` (Motion spring); reduced
20
+ * motion collapses to a flat view of just the active slide.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <TimeMachineStack
25
+ * defaultActive={0}
26
+ * slides={[
27
+ * <div class="flex h-full items-center justify-center p-6">Sunset over the bay</div>,
28
+ * <div class="flex h-full items-center justify-center p-6">Empty beach at dawn</div>,
29
+ * <div class="flex h-full items-center justify-center p-6">Fog through the pines</div>,
30
+ * ]}
31
+ * />
32
+ * ```
33
+ */
34
+ export declare function TimeMachineStack(props: TimeMachineStackProps): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -101,6 +101,7 @@
101
101
  "test:visual": "playwright test --config playwright.visual.config.ts",
102
102
  "test:report": "playwright show-report",
103
103
  "test:package": "node scripts/test-package.mjs",
104
+ "test:smoke": "node scripts/smoke-console.mjs",
104
105
  "validate": "npm run typecheck && npm run lint && npm run format:check && npm run test:unit && npm run build && npm run test:package",
105
106
  "prepublishOnly": "npm run validate",
106
107
  "prepare": "husky"
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // import '@a4ui/core/styles.css'
9
9
  // import { Button, Card, Modal } from '@a4ui/core'
10
10
 
11
- export const A4UI_VERSION = '0.29.1'
11
+ export const A4UI_VERSION = '0.30.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -160,6 +160,26 @@ export { FloatingToolbar, type FloatingToolbarProps } from './ui/FloatingToolbar
160
160
  export { PageTransition, type PageTransitionProps } from './ui/PageTransition'
161
161
  export { InlineSelect, type InlineSelectProps, type InlineSelectOption } from './ui/InlineSelect'
162
162
 
163
+ // Micro-interactions — animated buttons + card/3D transitions (Motion-driven).
164
+ export {
165
+ MicroButton,
166
+ type MicroButtonProps,
167
+ type MicroButtonEffect,
168
+ type MicroButtonVariant,
169
+ } from './ui/MicroButton'
170
+ export { IconMorphButton, type IconMorphButtonProps, type IconMorphButtonVariant } from './ui/IconMorphButton'
171
+ export { LikeButton, type LikeButtonProps, type LikeButtonIcon } from './ui/LikeButton'
172
+ export {
173
+ SlideArrowButton,
174
+ type SlideArrowButtonProps,
175
+ type SlideArrowButtonVariant,
176
+ type SlideArrowDirection,
177
+ } from './ui/SlideArrowButton'
178
+ export { FocusBlurGroup, type FocusBlurGroupProps } from './ui/FocusBlurGroup'
179
+ export { CardSpread, type CardSpreadProps, type CardSpreadLayout } from './ui/CardSpread'
180
+ export { Carousel3D, type Carousel3DProps, type Carousel3DVariant } from './ui/Carousel3D'
181
+ export { TimeMachineStack, type TimeMachineStackProps } from './ui/TimeMachineStack'
182
+
163
183
  // Phase 2 — code, search, navigation, master-detail.
164
184
  export { CodeTabs, type CodeTabsProps, type CodeTab } from './ui/CodeTabs'
165
185
  export { PillSearch, type PillSearchProps, type PillField } from './ui/PillSearch'
@@ -0,0 +1,194 @@
1
+ // CardSpread — a stack of cards that fans out into one of several layouts.
2
+ // Rest state is a tight near-centered stack; opening (controlled via `open`,
3
+ // or on hover when uncontrolled) spreads the cards per `layout`. Only
4
+ // `transform`/`opacity` are animated (Motion spring), never layout properties,
5
+ // so the fan stays compositor-only. Reduced motion renders the spread layout
6
+ // statically (no transition, no hover animation).
7
+ import { createEffect, createSignal, For, on, onCleanup, onMount, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced } from '../lib/motion'
11
+
12
+ /** Fan shape applied when a {@link CardSpread} opens. Defaults to `'arc'`. */
13
+ export type CardSpreadLayout = 'arc' | 'long-arc' | 'linear' | 'corner' | 'cascade' | 'scatter' | 'wheel'
14
+
15
+ export interface CardSpreadProps {
16
+ /** Card contents; each entry becomes one card in the stack. */
17
+ items: JSX.Element[]
18
+ /** Fan shape. @default 'arc' */
19
+ layout?: CardSpreadLayout
20
+ /** Controlled open state. Omit to spread on hover instead. */
21
+ open?: boolean
22
+ /** Strength knob: degrees for arced/wheel layouts, px for linear/corner/cascade/scatter. Per-layout default applies when omitted. */
23
+ spread?: number
24
+ class?: string
25
+ 'aria-label'?: string
26
+ }
27
+
28
+ interface CardTransform {
29
+ x: number
30
+ y: number
31
+ rotate: number
32
+ origin: string
33
+ }
34
+
35
+ const DEFAULT_SPREAD: Record<CardSpreadLayout, number> = {
36
+ arc: 10,
37
+ 'long-arc': 6,
38
+ linear: 56,
39
+ corner: 12,
40
+ cascade: 18,
41
+ scatter: 14,
42
+ wheel: 16,
43
+ }
44
+
45
+ // Deterministic pseudo-random offset in [-1, 1] from an index — no Math.random.
46
+ function pseudoRandom(i: number): number {
47
+ const s = Math.sin(i * 12.9898) * 43758.5453
48
+ return (s - Math.floor(s)) * 2 - 1
49
+ }
50
+
51
+ function restTransform(i: number): CardTransform {
52
+ // Tight near-centered stack: a hair of offset/rotation per card so the
53
+ // pile still reads as separate cards.
54
+ return { x: i * 1.5, y: -i * 1, rotate: (i % 2 === 0 ? 1 : -1) * 1.5, origin: 'bottom center' }
55
+ }
56
+
57
+ function openTransform(layout: CardSpreadLayout, i: number, n: number, spread: number): CardTransform {
58
+ const mid = (n - 1) / 2
59
+ const d = i - mid
60
+
61
+ switch (layout) {
62
+ case 'arc': {
63
+ const gap = 44
64
+ return { x: d * gap, y: Math.abs(d) ** 2 * 6, rotate: d * spread, origin: 'bottom center' }
65
+ }
66
+ case 'long-arc': {
67
+ const gap = 68
68
+ return { x: d * gap, y: Math.abs(d) ** 2 * 4, rotate: d * spread, origin: 'bottom center' }
69
+ }
70
+ case 'linear':
71
+ return { x: d * spread, y: 0, rotate: 0, origin: 'bottom center' }
72
+ case 'corner':
73
+ return { x: i * (spread * 0.6), y: -i * (spread * 0.15), rotate: i * spread, origin: 'bottom left' }
74
+ case 'cascade':
75
+ return {
76
+ x: i * spread,
77
+ y: i * (spread * 0.55),
78
+ rotate: (i % 2 === 0 ? 1 : -1) * 3,
79
+ origin: 'bottom center',
80
+ }
81
+ case 'scatter': {
82
+ const r = pseudoRandom(i)
83
+ const r2 = pseudoRandom(i + 100)
84
+ return { x: r * spread * 3, y: r2 * spread * 1.5, rotate: r * spread, origin: 'center center' }
85
+ }
86
+ case 'wheel':
87
+ return { x: d * 18, y: Math.abs(d) * 10, rotate: d * spread, origin: 'bottom center' }
88
+ }
89
+ }
90
+
91
+ function transformString(t: CardTransform): string {
92
+ return `translate(${t.x}px, ${t.y}px) rotate(${t.rotate}deg)`
93
+ }
94
+
95
+ /**
96
+ * A stack of cards that fans out into an arc, line, corner, cascade, scatter,
97
+ * or wheel shape. At rest the cards sit in a near-centered pile; opening
98
+ * (controlled via `open`, or on hover when uncontrolled) spreads them.
99
+ * Animates only `transform`/`opacity` (Motion spring) so the fan stays
100
+ * compositor-only, and falls back to a static fanned layout under reduced
101
+ * motion.
102
+ *
103
+ * @example
104
+ * ```tsx
105
+ * <CardSpread
106
+ * layout="arc"
107
+ * aria-label="Trip itinerary cards"
108
+ * items={[
109
+ * <div class="p-4">Day 1 — Arrival</div>,
110
+ * <div class="p-4">Day 2 — Old Town</div>,
111
+ * <div class="p-4">Day 3 — Coast drive</div>,
112
+ * ]}
113
+ * />
114
+ * ```
115
+ */
116
+ export function CardSpread(props: CardSpreadProps): JSX.Element {
117
+ const cardRefs: (HTMLElement | undefined)[] = []
118
+ const controls: (ReturnType<typeof animate> | undefined)[] = []
119
+ const [hovered, setHovered] = createSignal(false)
120
+
121
+ const layout = () => props.layout ?? 'arc'
122
+ const spread = () => props.spread ?? DEFAULT_SPREAD[layout()]
123
+ const isOpen = () => (props.open !== undefined ? props.open : hovered())
124
+
125
+ // Reduced motion always renders the fanned layout, statically, regardless
126
+ // of open/hover state — so `open`/`hover` never trigger visible motion.
127
+ const applyTransforms = (animated: boolean) => {
128
+ const n = props.items.length
129
+ const reduced = motionReduced()
130
+ const open = reduced ? true : isOpen()
131
+
132
+ cardRefs.forEach((card, i) => {
133
+ if (!card) return
134
+ const t = open ? openTransform(layout(), i, n, spread()) : restTransform(i)
135
+ card.style.transformOrigin = t.origin
136
+ card.style.zIndex = String(open ? i : n - i)
137
+
138
+ controls[i]?.stop()
139
+ if (reduced || !animated) {
140
+ card.style.transform = transformString(t)
141
+ return
142
+ }
143
+ controls[i] = animate(
144
+ card,
145
+ { x: t.x, y: t.y, rotate: t.rotate },
146
+ { type: 'spring', stiffness: 260, damping: 24 },
147
+ )
148
+ })
149
+ }
150
+
151
+ // Initial paint only: snap to the correct state (no animation) so a controlled
152
+ // `open` or a reduced-motion preference doesn't flash the closed stack first.
153
+ // Deliberately untracked (onMount, not createEffect) — it must run exactly
154
+ // once, otherwise it would re-fire on every hover/open change and snap the
155
+ // cards straight to their target, leaving the animated effect below nothing
156
+ // to spring from.
157
+ onMount(() => applyTransforms(false))
158
+ // Subsequent changes to open/hover/layout/spread animate in.
159
+ createEffect(
160
+ on([() => props.open, hovered, layout, spread, () => props.items.length], () => applyTransforms(true), {
161
+ defer: true,
162
+ }),
163
+ )
164
+ onCleanup(() => controls.forEach((c) => c?.stop()))
165
+
166
+ const handleEnter = () => {
167
+ if (props.open === undefined && !motionReduced()) setHovered(true)
168
+ }
169
+ const handleLeave = () => {
170
+ if (props.open === undefined && !motionReduced()) setHovered(false)
171
+ }
172
+
173
+ return (
174
+ <div
175
+ role="group"
176
+ aria-label={props['aria-label']}
177
+ class={cn('relative inline-block h-56 w-40', props.class)}
178
+ onPointerEnter={handleEnter}
179
+ onPointerLeave={handleLeave}
180
+ >
181
+ <For each={props.items}>
182
+ {(item, i) => (
183
+ <div
184
+ ref={(el) => (cardRefs[i()] = el)}
185
+ class="absolute inset-x-0 top-0 h-56 w-40 rounded-xl border border-border bg-card text-card-foreground shadow-sm will-change-transform"
186
+ style={{ transform: transformString(restTransform(i())) }}
187
+ >
188
+ {item}
189
+ </div>
190
+ )}
191
+ </For>
192
+ </div>
193
+ )
194
+ }