@a4ui/core 0.29.1 → 0.31.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,220 @@
1
+ // IconMorphButton — a two-state icon toggle whose glyph morphs between an
2
+ // inactive and active icon with a spring: the outgoing icon scales down +
3
+ // rotates + fades out while the incoming scales up + rotates in + fades in,
4
+ // both stacked absolutely inside a fixed-size box. Built for icon actions
5
+ // with an obvious before/after (copy → copied, mute → muted, follow →
6
+ // following). Optional label crossfades alongside the icon.
7
+ import type { JSX } from 'solid-js'
8
+ import { Show, createEffect, createSignal, onCleanup } from 'solid-js'
9
+
10
+ import { cn } from '../lib/cn'
11
+ import { animate, motionReduced } from '../lib/motion'
12
+
13
+ /** Visual style of an {@link IconMorphButton}. Defaults to `'ghost'`. */
14
+ export type IconMorphButtonVariant = 'solid' | 'ghost' | 'outline'
15
+
16
+ const VARIANT_CLASSES: Record<IconMorphButtonVariant, string> = {
17
+ solid: 'bg-primary text-primary-foreground hover:bg-primary/90',
18
+ outline: 'border border-border bg-transparent text-foreground hover:bg-muted',
19
+ ghost: 'bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground',
20
+ }
21
+
22
+ const BASE =
23
+ 'inline-flex h-9 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50'
24
+
25
+ const SPRING = { type: 'spring', stiffness: 380, damping: 30 } as const
26
+
27
+ export interface IconMorphButtonProps {
28
+ /** Icon shown while `pressed` is `false`, e.g. `<Copy size={18} />`. */
29
+ inactive: JSX.Element
30
+ /** Icon shown while `pressed` is `true`, e.g. `<Check size={18} />`. */
31
+ active: JSX.Element
32
+ /** Controlled pressed state. Omit to let the button manage its own state. */
33
+ pressed?: boolean
34
+ /** Initial pressed state when uncontrolled. Defaults to `false`. */
35
+ defaultPressed?: boolean
36
+ /** Fired with the next pressed state, controlled or uncontrolled. */
37
+ onChange?: (pressed: boolean) => void
38
+ /**
39
+ * When greater than `0`, auto-reverts to `inactive` after this many ms once
40
+ * pressed (e.g. copy → check → copy). The timer is cleared on cleanup and
41
+ * restarted on every re-press. @default 0
42
+ */
43
+ revertAfter?: number
44
+ /** Optional label shown next to the icon while `pressed` is `false`. */
45
+ label?: JSX.Element
46
+ /** Optional label shown next to the icon while `pressed` is `true`. */
47
+ activeLabel?: JSX.Element
48
+ /** Visual style. Defaults to `'ghost'`. */
49
+ variant?: IconMorphButtonVariant
50
+ class?: string
51
+ 'aria-label'?: string
52
+ }
53
+
54
+ /**
55
+ * Two-state icon toggle button whose icon morphs (spring scale + rotate +
56
+ * fade) between `inactive` and `active` glyphs, stacked in a fixed-size box.
57
+ * Supports controlled (`pressed`/`onChange`) or uncontrolled
58
+ * (`defaultPressed`) use, an optional auto-revert timer, and an optional
59
+ * crossfading label. Falls back to an instant swap under reduced motion.
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * <IconMorphButton
64
+ * inactive={<Copy size={18} />}
65
+ * active={<Check size={18} />}
66
+ * aria-label="Copy to clipboard"
67
+ * revertAfter={1500}
68
+ * onChange={(pressed) => {
69
+ * if (pressed) void navigator.clipboard?.writeText('npm install @a4ui/core')
70
+ * }}
71
+ * />
72
+ * ```
73
+ */
74
+ export function IconMorphButton(props: IconMorphButtonProps): JSX.Element {
75
+ const [internalPressed, setInternalPressed] = createSignal(props.defaultPressed ?? false)
76
+ const pressed = (): boolean => props.pressed ?? internalPressed()
77
+
78
+ const setPressed = (next: boolean): void => {
79
+ if (props.pressed === undefined) setInternalPressed(next)
80
+ props.onChange?.(next)
81
+ }
82
+
83
+ let revertTimer: ReturnType<typeof setTimeout> | undefined
84
+
85
+ const handleClick = (): void => {
86
+ clearTimeout(revertTimer)
87
+ const next = !pressed()
88
+ setPressed(next)
89
+ const revertAfter = props.revertAfter ?? 0
90
+ if (next && revertAfter > 0) {
91
+ revertTimer = setTimeout(() => setPressed(false), revertAfter)
92
+ }
93
+ }
94
+
95
+ let inactiveIconEl: HTMLSpanElement | undefined
96
+ let activeIconEl: HTMLSpanElement | undefined
97
+ let inactiveLabelEl: HTMLSpanElement | undefined
98
+ let activeLabelEl: HTMLSpanElement | undefined
99
+
100
+ let iconOutControls: ReturnType<typeof animate> | undefined
101
+ let iconInControls: ReturnType<typeof animate> | undefined
102
+ let labelOutControls: ReturnType<typeof animate> | undefined
103
+ let labelInControls: ReturnType<typeof animate> | undefined
104
+
105
+ const setInstant = (el: HTMLElement | undefined, visible: boolean, morph: boolean): void => {
106
+ if (!el) return
107
+ el.style.opacity = visible ? '1' : '0'
108
+ if (morph) el.style.transform = visible ? 'scale(1) rotate(0deg)' : 'scale(0.6) rotate(0deg)'
109
+ }
110
+
111
+ const applyInstant = (isPressed: boolean): void => {
112
+ setInstant(inactiveIconEl, !isPressed, true)
113
+ setInstant(activeIconEl, isPressed, true)
114
+ setInstant(inactiveLabelEl, !isPressed, false)
115
+ setInstant(activeLabelEl, isPressed, false)
116
+ }
117
+
118
+ let isFirstRun = true
119
+
120
+ createEffect(() => {
121
+ const isPressed = pressed()
122
+
123
+ if (isFirstRun) {
124
+ isFirstRun = false
125
+ applyInstant(isPressed)
126
+ return
127
+ }
128
+
129
+ iconOutControls?.stop()
130
+ iconInControls?.stop()
131
+ labelOutControls?.stop()
132
+ labelInControls?.stop()
133
+
134
+ if (motionReduced()) {
135
+ applyInstant(isPressed)
136
+ return
137
+ }
138
+
139
+ const outgoingIcon = isPressed ? inactiveIconEl : activeIconEl
140
+ const incomingIcon = isPressed ? activeIconEl : inactiveIconEl
141
+ const outRotate = isPressed ? 90 : -90
142
+ const inRotate = isPressed ? -90 : 90
143
+
144
+ if (outgoingIcon) {
145
+ iconOutControls = animate(
146
+ outgoingIcon,
147
+ { opacity: [1, 0], scale: [1, 0.6], rotate: [0, outRotate] },
148
+ SPRING,
149
+ )
150
+ }
151
+ if (incomingIcon) {
152
+ iconInControls = animate(
153
+ incomingIcon,
154
+ { opacity: [0, 1], scale: [0.6, 1], rotate: [inRotate, 0] },
155
+ SPRING,
156
+ )
157
+ }
158
+
159
+ const outgoingLabel = isPressed ? inactiveLabelEl : activeLabelEl
160
+ const incomingLabel = isPressed ? activeLabelEl : inactiveLabelEl
161
+
162
+ if (outgoingLabel) {
163
+ labelOutControls = animate(outgoingLabel, { opacity: [1, 0] }, { duration: 0.18, ease: 'easeOut' })
164
+ }
165
+ if (incomingLabel) {
166
+ labelInControls = animate(incomingLabel, { opacity: [0, 1] }, { duration: 0.18, ease: 'easeOut' })
167
+ }
168
+ })
169
+
170
+ onCleanup(() => {
171
+ clearTimeout(revertTimer)
172
+ iconOutControls?.stop()
173
+ iconInControls?.stop()
174
+ labelOutControls?.stop()
175
+ labelInControls?.stop()
176
+ })
177
+
178
+ const hasLabel = (): boolean => props.label !== undefined || props.activeLabel !== undefined
179
+
180
+ return (
181
+ <button
182
+ type="button"
183
+ aria-pressed={pressed()}
184
+ aria-label={props['aria-label']}
185
+ onClick={handleClick}
186
+ class={cn(BASE, hasLabel() ? 'px-3' : 'w-9', VARIANT_CLASSES[props.variant ?? 'ghost'], props.class)}
187
+ >
188
+ <span class="relative inline-block h-5 w-5 shrink-0">
189
+ <span
190
+ ref={inactiveIconEl}
191
+ class="absolute inset-0 flex items-center justify-center will-change-transform"
192
+ aria-hidden={pressed()}
193
+ >
194
+ {props.inactive}
195
+ </span>
196
+ <span
197
+ ref={activeIconEl}
198
+ class="absolute inset-0 flex items-center justify-center will-change-transform"
199
+ aria-hidden={!pressed()}
200
+ >
201
+ {props.active}
202
+ </span>
203
+ </span>
204
+ <Show when={hasLabel()}>
205
+ <span class="inline-grid">
206
+ <Show when={props.label}>
207
+ <span ref={inactiveLabelEl} class="col-start-1 row-start-1" aria-hidden={pressed()}>
208
+ {props.label}
209
+ </span>
210
+ </Show>
211
+ <Show when={props.activeLabel}>
212
+ <span ref={activeLabelEl} class="col-start-1 row-start-1" aria-hidden={!pressed()}>
213
+ {props.activeLabel}
214
+ </span>
215
+ </Show>
216
+ </span>
217
+ </Show>
218
+ </button>
219
+ )
220
+ }
@@ -0,0 +1,199 @@
1
+ // LikeButton — a like/favorite/save-later toggle driven by the `icon` prop
2
+ // (heart/star/bookmark) instead of three near-identical components. On
3
+ // becoming pressed the icon fills, morphs to the accent color, springs a
4
+ // scale pop (Motion's `spring`), and bursts a handful of tiny icon copies
5
+ // outward (staggered via `stagger`) before fading. Only `transform`/`opacity`
6
+ // are animated, matching this file's Motion helpers (see ../lib/motion.ts).
7
+ import { Bookmark, Heart, Star, type LucideProps } from 'lucide-solid'
8
+ import type { Component, JSX } from 'solid-js'
9
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
10
+ import { Dynamic } from 'solid-js/web'
11
+
12
+ import { cn } from '../lib/cn'
13
+ import { animate, motionReduced, spring, stagger } from '../lib/motion'
14
+
15
+ /** Which icon a {@link LikeButton} renders — picks the semantic use (like/favorite/save). */
16
+ export type LikeButtonIcon = 'heart' | 'star' | 'bookmark'
17
+
18
+ const ICONS: Record<LikeButtonIcon, Component<LucideProps>> = {
19
+ heart: Heart,
20
+ star: Star,
21
+ bookmark: Bookmark,
22
+ }
23
+
24
+ const BURST_COUNT = 5
25
+ const BURST_RADIUS_PX = 18
26
+ const BURST_STAGGER_S = 0.03
27
+ const BURST_DURATION_S = 0.5
28
+
29
+ interface BurstParticle {
30
+ id: number
31
+ angle: number
32
+ }
33
+
34
+ export interface LikeButtonProps {
35
+ /** Controlled pressed state. When set, wins over internal state — pair with `onChange`. */
36
+ pressed?: boolean
37
+ /** Initial pressed state for uncontrolled use. @default false */
38
+ defaultPressed?: boolean
39
+ onChange?: (pressed: boolean) => void
40
+ /** Which icon to render. @default 'heart' */
41
+ icon?: LikeButtonIcon
42
+ /** Count shown next to the icon (e.g. total likes). */
43
+ count?: number
44
+ /** Show `count`. @default false */
45
+ showCount?: boolean
46
+ /** Class applied to the icon/count when pressed. @default 'text-primary' */
47
+ activeClass?: string
48
+ class?: string
49
+ 'aria-label'?: string
50
+ }
51
+
52
+ /**
53
+ * Like/favorite/save-later toggle button. One component covers all three via
54
+ * `icon` ('heart' | 'star' | 'bookmark') rather than three near-identical
55
+ * toggles. On becoming pressed the icon fills, morphs to `activeClass`,
56
+ * springs a scale pop, and bursts a handful of tiny icon copies outward —
57
+ * skipped under reduced motion, which just flips the filled/unfilled state
58
+ * instantly. Works controlled (`pressed` + `onChange`) or uncontrolled
59
+ * (`defaultPressed`).
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * <LikeButton defaultPressed count={128} showCount aria-label="Like this post" />
64
+ * ```
65
+ */
66
+ export function LikeButton(props: LikeButtonProps): JSX.Element {
67
+ const [internalPressed, setInternalPressed] = createSignal(props.defaultPressed ?? false)
68
+ const pressed = createMemo(() => props.pressed ?? internalPressed())
69
+ const Icon = createMemo(() => ICONS[props.icon ?? 'heart'])
70
+
71
+ const [particles, setParticles] = createSignal<BurstParticle[]>([])
72
+ const particleEls = new Map<number, HTMLSpanElement>()
73
+ let particleId = 0
74
+ let iconEl: SVGSVGElement | undefined
75
+ let countEl: HTMLSpanElement | undefined
76
+ let popControls: ReturnType<typeof animate> | undefined
77
+ let disposed = false
78
+
79
+ onCleanup(() => {
80
+ disposed = true
81
+ popControls?.stop()
82
+ })
83
+
84
+ // Fade + slide the count in whenever it changes (not on initial mount), skipped under reduced motion.
85
+ let hasMountedCount = false
86
+ createEffect(() => {
87
+ void props.count
88
+ if (hasMountedCount && countEl && !motionReduced()) {
89
+ animate(countEl, { opacity: [0, 1], y: [-6, 0] }, { duration: 0.22, ease: 'easeOut' })
90
+ }
91
+ hasMountedCount = true
92
+ })
93
+
94
+ const playPop = (): void => {
95
+ if (!iconEl) return
96
+ popControls?.stop()
97
+ const el = iconEl
98
+ popControls = animate(el, { scale: 1.3 }, { type: spring, stiffness: 600, damping: 10 })
99
+ popControls.finished
100
+ .then(() => {
101
+ if (disposed) return
102
+ popControls = animate(el, { scale: 1 }, { type: spring, stiffness: 500, damping: 14 })
103
+ })
104
+ .catch(() => {})
105
+ }
106
+
107
+ const spawnBurst = (): void => {
108
+ const delayFor = stagger(BURST_STAGGER_S)
109
+ const next: BurstParticle[] = Array.from({ length: BURST_COUNT }, (_, index) => ({
110
+ id: particleId++,
111
+ angle: (360 / BURST_COUNT) * index,
112
+ }))
113
+ setParticles((current) => [...current, ...next])
114
+
115
+ queueMicrotask(() => {
116
+ if (disposed) return
117
+ const finished = next.map((particle, index) => {
118
+ const el = particleEls.get(particle.id)
119
+ if (!el) return Promise.resolve()
120
+ const radians = (particle.angle * Math.PI) / 180
121
+ const dx = Math.cos(radians) * BURST_RADIUS_PX
122
+ const dy = Math.sin(radians) * BURST_RADIUS_PX
123
+ const controls = animate(
124
+ el,
125
+ {
126
+ transform: [
127
+ 'translate(-50%, -50%) scale(0.9)',
128
+ `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.3)`,
129
+ ],
130
+ opacity: [1, 0],
131
+ },
132
+ { duration: BURST_DURATION_S, delay: delayFor(index, next.length), ease: 'easeOut' },
133
+ )
134
+ return controls.finished
135
+ })
136
+
137
+ Promise.allSettled(finished).then(() => {
138
+ for (const particle of next) particleEls.delete(particle.id)
139
+ if (disposed) return
140
+ setParticles((current) => current.filter((p) => !next.some((n) => n.id === p.id)))
141
+ })
142
+ })
143
+ }
144
+
145
+ const handleClick = (): void => {
146
+ const next = !pressed()
147
+ if (props.pressed === undefined) setInternalPressed(next)
148
+ props.onChange?.(next)
149
+
150
+ if (!next || motionReduced()) return
151
+ playPop()
152
+ spawnBurst()
153
+ }
154
+
155
+ return (
156
+ <button
157
+ type="button"
158
+ aria-pressed={pressed()}
159
+ aria-label={props['aria-label']}
160
+ class={cn(
161
+ 'inline-flex items-center gap-1.5 rounded-md text-muted-foreground transition-colors hover:text-foreground',
162
+ pressed() && (props.activeClass ?? 'text-primary'),
163
+ props.class,
164
+ )}
165
+ onClick={handleClick}
166
+ >
167
+ <span class="relative inline-flex h-5 w-5 items-center justify-center">
168
+ <Dynamic
169
+ component={Icon()}
170
+ ref={(el: SVGSVGElement) => {
171
+ iconEl = el
172
+ }}
173
+ class={cn('h-5 w-5 will-change-transform', pressed() && 'fill-current')}
174
+ />
175
+ <span class="pointer-events-none absolute inset-0" aria-hidden="true">
176
+ <For each={particles()}>
177
+ {(particle) => (
178
+ <span
179
+ ref={(el) => particleEls.set(particle.id, el)}
180
+ class="absolute left-1/2 top-1/2 h-2.5 w-2.5 will-change-transform"
181
+ style={{ transform: 'translate(-50%, -50%)' }}
182
+ >
183
+ <Dynamic
184
+ component={Icon()}
185
+ class={cn('h-2.5 w-2.5 fill-current', props.activeClass ?? 'text-primary')}
186
+ />
187
+ </span>
188
+ )}
189
+ </For>
190
+ </span>
191
+ </span>
192
+ <Show when={props.showCount}>
193
+ <span ref={countEl} class="tabular-nums will-change-transform">
194
+ {props.count}
195
+ </span>
196
+ </Show>
197
+ </button>
198
+ )
199
+ }
@@ -0,0 +1,205 @@
1
+ // MicroButton — small icon/label button that plays a short, physically-tuned
2
+ // feedback micro-animation on interaction: spin, shake, pulse, ring, sparkle
3
+ // (on click), or a diagonal glare sweep (on hover). Every effect animates only
4
+ // transform/opacity (never layout), and decorative particles spawn then
5
+ // remove themselves — the same one-shot pattern as spawnRipple in Ripple.tsx.
6
+ // No-op (plain static button) under reduced motion.
7
+ import { type JSX, Show, splitProps } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced, stagger } from '../lib/motion'
11
+
12
+ /** Feedback micro-animation a {@link MicroButton} plays. Defaults to `'ring'`. */
13
+ export type MicroButtonEffect = 'spin' | 'shake' | 'pulse' | 'ring' | 'sparkle' | 'glare'
14
+
15
+ /** Visual style of a {@link MicroButton}. Defaults to `'solid'`. */
16
+ export type MicroButtonVariant = 'solid' | 'ghost' | 'outline'
17
+
18
+ const VARIANT_CLASSES: Record<MicroButtonVariant, string> = {
19
+ solid: 'bg-primary text-primary-foreground',
20
+ ghost: 'text-foreground hover:bg-muted',
21
+ outline: 'border border-border text-foreground',
22
+ }
23
+
24
+ const BASE =
25
+ 'micro-button relative inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'
26
+
27
+ const PARTICLE_BASE = 'pointer-events-none absolute will-change-transform'
28
+
29
+ export interface MicroButtonProps {
30
+ children?: JSX.Element
31
+ /** Feedback micro-animation. `'glare'` plays on hover; the rest play on click. @default 'ring' */
32
+ effect?: MicroButtonEffect
33
+ onClick?: (e: MouseEvent) => void
34
+ /** Visual style. @default 'solid' */
35
+ variant?: MicroButtonVariant
36
+ disabled?: boolean
37
+ class?: string
38
+ 'aria-label'?: string
39
+ }
40
+
41
+ /**
42
+ * Small icon/label button that plays a short feedback micro-animation on
43
+ * interaction — spin, shake, pulse, ring, or sparkle on click, or a diagonal
44
+ * glare sweep on hover. Every effect animates only `transform`/`opacity`, so
45
+ * it stays cheap even on low-end devices; decorative particles are spawned
46
+ * and removed on completion, never left in the DOM. Renders a plain static
47
+ * `<button>` with no effect under reduced motion.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * <MicroButton effect="sparkle" aria-label="Like">
52
+ * <Heart size={16} />
53
+ * </MicroButton>
54
+ * ```
55
+ */
56
+ export function MicroButton(props: MicroButtonProps): JSX.Element {
57
+ const [local, rest] = splitProps(props, ['children', 'effect', 'onClick', 'variant', 'disabled', 'class'])
58
+
59
+ let buttonEl!: HTMLButtonElement
60
+ let contentEl!: HTMLSpanElement
61
+ let glareEl: HTMLSpanElement | undefined
62
+ let spins = 0
63
+
64
+ // Every in-flight animation registers a stopper here so onCleanup can halt
65
+ // it (and any pending particle removal) if the button unmounts mid-effect.
66
+ const activeStops = new Set<() => void>()
67
+
68
+ const track = (stop: () => void): (() => void) => {
69
+ activeStops.add(stop)
70
+ return () => activeStops.delete(stop)
71
+ }
72
+
73
+ const trackTransient = (controls: ReturnType<typeof animate>): void => {
74
+ const untrack = track(() => controls.stop())
75
+ controls.then(untrack)
76
+ }
77
+
78
+ const spawnParticle = (build: (el: HTMLSpanElement) => void): HTMLSpanElement => {
79
+ const el = document.createElement('span')
80
+ el.setAttribute('aria-hidden', 'true')
81
+ el.className = PARTICLE_BASE
82
+ build(el)
83
+ buttonEl.appendChild(el)
84
+ return el
85
+ }
86
+
87
+ const trackParticle = (el: HTMLSpanElement, controls: ReturnType<typeof animate>): void => {
88
+ const untrack = track(() => controls.stop())
89
+ const finish = (): void => {
90
+ untrack()
91
+ el.remove()
92
+ }
93
+ controls.then(finish)
94
+ }
95
+
96
+ const playSpin = (): void => {
97
+ spins += 360
98
+ trackTransient(animate(contentEl, { rotate: spins }, { type: 'spring', stiffness: 180, damping: 14 }))
99
+ }
100
+
101
+ const playShake = (): void => {
102
+ trackTransient(animate(contentEl, { x: [0, -4, 4, -3, 3, 0] }, { duration: 0.4, ease: 'easeInOut' }))
103
+ }
104
+
105
+ const spawnRing = (delay: number): void => {
106
+ const el = spawnParticle((e) => {
107
+ e.classList.add('inset-0', 'rounded-full', 'border-2', 'border-current')
108
+ })
109
+ trackParticle(
110
+ el,
111
+ animate(el, { scale: [1, 1.8], opacity: [0.6, 0] }, { duration: 0.7, delay, ease: 'easeOut' }),
112
+ )
113
+ }
114
+
115
+ const playRing = (): void => spawnRing(0)
116
+
117
+ const playPulse = (): void => {
118
+ spawnRing(0)
119
+ spawnRing(0.15)
120
+ }
121
+
122
+ const playSparkle = (): void => {
123
+ const count = 6
124
+ const radius = 20
125
+ for (let i = 0; i < count; i++) {
126
+ const angle = (i / count) * Math.PI * 2
127
+ const dx = Math.cos(angle) * radius
128
+ const dy = Math.sin(angle) * radius
129
+ const el = spawnParticle((e) => {
130
+ e.classList.add('left-1/2', 'top-1/2', 'h-1', 'w-1', 'rounded-full', 'bg-current')
131
+ e.style.marginLeft = '-2px'
132
+ e.style.marginTop = '-2px'
133
+ })
134
+ trackParticle(
135
+ el,
136
+ animate(
137
+ el,
138
+ { x: [0, dx], y: [0, dy], rotate: [0, 180], opacity: [1, 0], scale: [1, 0.4] },
139
+ { duration: 0.55, delay: stagger(0.03)(i, count), ease: 'easeOut' },
140
+ ),
141
+ )
142
+ }
143
+ }
144
+
145
+ const handleClick = (event: MouseEvent): void => {
146
+ local.onClick?.(event)
147
+ if (local.disabled || motionReduced()) return
148
+ switch (local.effect ?? 'ring') {
149
+ case 'spin':
150
+ playSpin()
151
+ break
152
+ case 'shake':
153
+ playShake()
154
+ break
155
+ case 'pulse':
156
+ playPulse()
157
+ break
158
+ case 'sparkle':
159
+ playSparkle()
160
+ break
161
+ case 'ring':
162
+ playRing()
163
+ break
164
+ // 'glare' plays on hover, not click — see handlePointerEnter.
165
+ }
166
+ }
167
+
168
+ const handlePointerEnter = (): void => {
169
+ if ((local.effect ?? 'ring') !== 'glare' || !glareEl || motionReduced()) return
170
+ trackTransient(
171
+ animate(glareEl, { x: ['-100%', '250%'], opacity: [0, 1, 0] }, { duration: 0.6, ease: 'easeInOut' }),
172
+ )
173
+ }
174
+
175
+ return (
176
+ <button
177
+ ref={buttonEl}
178
+ type="button"
179
+ class={cn(
180
+ BASE,
181
+ VARIANT_CLASSES[local.variant ?? 'solid'],
182
+ (local.effect ?? 'ring') === 'glare' && 'overflow-hidden',
183
+ local.class,
184
+ )}
185
+ disabled={local.disabled}
186
+ onClick={handleClick}
187
+ onPointerEnter={handlePointerEnter}
188
+ {...rest}
189
+ >
190
+ <span
191
+ ref={contentEl}
192
+ class="relative inline-flex items-center justify-center gap-2 will-change-transform"
193
+ >
194
+ {local.children}
195
+ </span>
196
+ <Show when={(local.effect ?? 'ring') === 'glare'}>
197
+ <span
198
+ ref={glareEl}
199
+ aria-hidden="true"
200
+ class="pointer-events-none absolute inset-y-0 left-0 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-foreground/25 to-transparent opacity-0 will-change-transform"
201
+ />
202
+ </Show>
203
+ </button>
204
+ )
205
+ }