@a4ui/core 0.15.0 → 0.17.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.
@@ -1,6 +1,7 @@
1
1
  import type { JSX } from 'solid-js'
2
2
 
3
3
  import { cn } from '../lib/cn'
4
+ import { spawnRipple } from './Ripple'
4
5
 
5
6
  /** Screen corner a {@link FloatingActionButton} anchors to. Defaults to `'bottom-right'`. */
6
7
  export type FloatingActionButtonPosition = 'bottom-right' | 'bottom-left'
@@ -23,6 +24,12 @@ export interface FloatingActionButtonProps {
23
24
  onClick?: () => void
24
25
  /** Screen corner to anchor to. Defaults to `'bottom-right'`. */
25
26
  position?: FloatingActionButtonPosition
27
+ /**
28
+ * Material-style click ripple from the press position. Engine-free (Web
29
+ * Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
30
+ * costs nothing when off.
31
+ */
32
+ ripple?: boolean
26
33
  class?: string
27
34
  }
28
35
 
@@ -40,7 +47,15 @@ export function FloatingActionButton(props: FloatingActionButtonProps): JSX.Elem
40
47
  type="button"
41
48
  aria-label={props.label}
42
49
  onClick={() => props.onClick?.()}
43
- class={cn(FAB_BASE, POSITION_CLASSES[props.position ?? 'bottom-right'], props.class)}
50
+ onPointerDown={(event) => {
51
+ if (props.ripple) spawnRipple(event.currentTarget, event, { opacity: 0.35 })
52
+ }}
53
+ class={cn(
54
+ FAB_BASE,
55
+ POSITION_CLASSES[props.position ?? 'bottom-right'],
56
+ props.ripple && 'relative overflow-hidden',
57
+ props.class,
58
+ )}
44
59
  >
45
60
  {props.icon}
46
61
  </button>
package/src/ui/Image.tsx CHANGED
@@ -8,6 +8,7 @@ import type { JSX } from 'solid-js'
8
8
  import { createSignal, Show } from 'solid-js'
9
9
 
10
10
  import { cn } from '../lib/cn'
11
+ import { motionReduced } from '../lib/motion'
11
12
 
12
13
  export interface ImageProps {
13
14
  src: string
@@ -15,6 +16,12 @@ export interface ImageProps {
15
16
  class?: string
16
17
  /** When true, clicking the image opens a zoomable lightbox. @default true */
17
18
  preview?: boolean
19
+ /**
20
+ * Reveal the thumbnail with a blur-up: it starts blurred and slightly scaled,
21
+ * then eases to sharp once the image finishes loading. Pure CSS transition,
22
+ * no-op under reduced motion. @default false
23
+ */
24
+ blurUp?: boolean
18
25
  }
19
26
 
20
27
  /**
@@ -32,8 +39,29 @@ export function Image(props: ImageProps): JSX.Element {
32
39
  const [open, setOpen] = createSignal(false)
33
40
  const preview = () => props.preview !== false
34
41
 
42
+ // blur-up: hidden behind the blur until `load` fires (or immediately, if the
43
+ // image is already cached or reduced motion is on).
44
+ const [loaded, setLoaded] = createSignal(false)
45
+ const blurUp = () => props.blurUp === true && !motionReduced()
46
+ const revealed = () => !blurUp() || loaded()
47
+
35
48
  const img = (
36
- <img src={props.src} alt={props.alt} loading="lazy" class={cn('rounded-lg object-cover', props.class)} />
49
+ <img
50
+ src={props.src}
51
+ alt={props.alt}
52
+ loading="lazy"
53
+ onLoad={() => setLoaded(true)}
54
+ ref={(el) => {
55
+ // A cached image can be complete before `onLoad` binds — reveal it now.
56
+ if (el.complete) setLoaded(true)
57
+ }}
58
+ class={cn(
59
+ 'rounded-lg object-cover',
60
+ blurUp() && 'transition-[filter,transform] duration-500 ease-out',
61
+ blurUp() && !revealed() && 'scale-[1.03] blur-lg',
62
+ props.class,
63
+ )}
64
+ />
37
65
  )
38
66
 
39
67
  return (
package/src/ui/List.tsx CHANGED
@@ -16,6 +16,11 @@ export interface ListItem {
16
16
 
17
17
  interface ListProps {
18
18
  items: ListItem[]
19
+ /**
20
+ * Cascade the rows in on mount — each row fades/slides up staggered by its
21
+ * index. Pure CSS (`list-row-stagger`), reduced-motion aware. @default false
22
+ */
23
+ stagger?: boolean
19
24
  class?: string
20
25
  }
21
26
 
@@ -46,8 +51,11 @@ export function List(props: ListProps): JSX.Element {
46
51
  return (
47
52
  <ul class={cn('divide-y divide-border rounded-lg border border-border', props.class)}>
48
53
  <For each={props.items}>
49
- {(item) => (
50
- <li class="flex items-center gap-3 px-4 py-3">
54
+ {(item, i) => (
55
+ <li
56
+ class={cn('flex items-center gap-3 px-4 py-3', props.stagger && 'list-row-stagger')}
57
+ style={props.stagger ? { 'animation-delay': `${i() * 60}ms` } : undefined}
58
+ >
51
59
  <Show when={item.avatar}>
52
60
  <div class="shrink-0">{item.avatar}</div>
53
61
  </Show>
package/src/ui/Ripple.tsx CHANGED
@@ -1,15 +1,13 @@
1
- // Material-style click ripple: a wrapper that spawns an expanding, fading
2
- // circle from the pointer's down position on every `pointerdown` Motion's
3
- // `animate` drives the scale/opacity tween since each ripple is a one-shot
4
- // spawn-then-discard element, not a persistent CSS transition. `x`/`y` are
5
- // animated to a constant -50% alongside `scale` so Motion (which owns the
6
- // element's `transform` once it's animating any transform sub-property)
7
- // composes `translate(-50%,-50%) scale(...)` itself rather than clobbering a
8
- // translate we'd otherwise have set by hand via inline style.
9
- import { createSignal, For, onCleanup, type JSX } from 'solid-js'
1
+ // Material-style click ripple: an expanding, fading circle from the pointer's
2
+ // down position on every `pointerdown`. The tween is driven by the native Web
3
+ // Animations API (el.animate) each ripple is a one-shot spawn-then-discard
4
+ // element, so there's nothing for a JS animation engine to add. Being
5
+ // engine-free is deliberate: it lets <Button ripple> bake this in without
6
+ // pulling the `motion` package into every Button consumer's bundle.
7
+ import { type JSX } from 'solid-js'
10
8
 
11
9
  import { cn } from '../lib/cn'
12
- import { animate, motionReduced } from '../lib/motion'
10
+ import { motionReduced } from '../lib/motion'
13
11
 
14
12
  export interface RippleProps {
15
13
  children: JSX.Element
@@ -20,14 +18,58 @@ export interface RippleProps {
20
18
  class?: string
21
19
  }
22
20
 
23
- interface RippleInstance {
24
- id: number
25
- x: number
26
- y: number
27
- size: number
28
- }
21
+ /**
22
+ * Spawns one Material-style ripple inside `container` at the pointer's
23
+ * position, expanding to cover the container and fading out, then removes
24
+ * itself. The container must be `position: relative; overflow: hidden`.
25
+ * Engine-free (Web Animations API); no-op under reduced motion. This is the
26
+ * primitive behind {@link Ripple} and `<Button ripple>`.
27
+ */
28
+ export function spawnRipple(
29
+ container: HTMLElement,
30
+ event: PointerEvent,
31
+ opts: { color?: string; opacity?: number } = {},
32
+ ): void {
33
+ if (motionReduced()) return
34
+ const rect = container.getBoundingClientRect()
35
+ const x = event.clientX - rect.left
36
+ const y = event.clientY - rect.top
37
+ // Diameter = 2× the distance to the farthest corner, so the circle always
38
+ // covers the whole container from wherever the press landed.
39
+ const size =
40
+ 2 *
41
+ Math.max(
42
+ Math.hypot(x, y),
43
+ Math.hypot(rect.width - x, y),
44
+ Math.hypot(x, rect.height - y),
45
+ Math.hypot(rect.width - x, rect.height - y),
46
+ )
29
47
 
30
- let nextRippleId = 0
48
+ const el = document.createElement('span')
49
+ el.setAttribute('aria-hidden', 'true')
50
+ Object.assign(el.style, {
51
+ position: 'absolute',
52
+ left: `${x}px`,
53
+ top: `${y}px`,
54
+ width: `${size}px`,
55
+ height: `${size}px`,
56
+ borderRadius: '9999px',
57
+ background: opts.color ?? 'currentColor',
58
+ pointerEvents: 'none',
59
+ transform: 'translate(-50%,-50%) scale(0)',
60
+ })
61
+ container.appendChild(el)
62
+
63
+ const animation = el.animate(
64
+ [
65
+ { transform: 'translate(-50%,-50%) scale(0)', opacity: String(opts.opacity ?? 0.3) },
66
+ { transform: 'translate(-50%,-50%) scale(1)', opacity: '0' },
67
+ ],
68
+ { duration: 600, easing: 'ease-out' },
69
+ )
70
+ const done = (): void => el.remove()
71
+ animation.finished.then(done).catch(done)
72
+ }
31
73
 
32
74
  /**
33
75
  * Wraps `children` in a `position: relative; overflow: hidden` layer that
@@ -37,59 +79,23 @@ let nextRippleId = 0
37
79
  * itself once its animation finishes. A no-op under reduced motion: children
38
80
  * still render, but no ripple ever spawns.
39
81
  *
82
+ * Buttons don't need the wrapper — use `<Button ripple>` instead.
83
+ *
40
84
  * @example
41
85
  * ```tsx
42
86
  * <Ripple color="white" opacity={0.25}>
43
- * <button class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</button>
87
+ * <div class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</div>
44
88
  * </Ripple>
45
89
  * ```
46
90
  */
47
91
  export function Ripple(props: RippleProps): JSX.Element {
48
- const [ripples, setRipples] = createSignal<RippleInstance[]>([])
49
- const active = new Set<ReturnType<typeof animate>>()
50
-
51
92
  let containerEl: HTMLSpanElement | undefined
52
93
 
53
- const remove = (id: number): void => {
54
- setRipples((current) => current.filter((ripple) => ripple.id !== id))
55
- }
56
-
57
- const spawnAnimation = (el: HTMLSpanElement, id: number, opacity: number): void => {
58
- const controls = animate(
59
- el,
60
- { x: ['-50%', '-50%'], y: ['-50%', '-50%'], scale: [0, 1], opacity: [opacity, 0] },
61
- { duration: 0.6, ease: 'easeOut' },
62
- )
63
- active.add(controls)
64
- controls.finished
65
- .then(() => {
66
- active.delete(controls)
67
- remove(id)
68
- })
69
- .catch(() => {})
70
- }
71
-
72
94
  const handlePointerDown = (event: PointerEvent): void => {
73
- if (motionReduced() || !containerEl) return
74
-
75
- const rect = containerEl.getBoundingClientRect()
76
- const x = event.clientX - rect.left
77
- const y = event.clientY - rect.top
78
- const maxDist = Math.max(
79
- Math.hypot(x, y),
80
- Math.hypot(rect.width - x, y),
81
- Math.hypot(x, rect.height - y),
82
- Math.hypot(rect.width - x, rect.height - y),
83
- )
84
-
85
- setRipples((current) => [...current, { id: nextRippleId++, x, y, size: maxDist * 2 }])
95
+ if (!containerEl) return
96
+ spawnRipple(containerEl, event, { color: props.color, opacity: props.opacity })
86
97
  }
87
98
 
88
- onCleanup(() => {
89
- active.forEach((controls) => controls.stop())
90
- active.clear()
91
- })
92
-
93
99
  return (
94
100
  <span
95
101
  ref={containerEl}
@@ -97,23 +103,6 @@ export function Ripple(props: RippleProps): JSX.Element {
97
103
  onPointerDown={handlePointerDown}
98
104
  >
99
105
  {props.children}
100
- <span aria-hidden="true" class="pointer-events-none absolute inset-0">
101
- <For each={ripples()}>
102
- {(ripple) => (
103
- <span
104
- ref={(el) => spawnAnimation(el, ripple.id, props.opacity ?? 0.3)}
105
- class="absolute rounded-full"
106
- style={{
107
- left: `${ripple.x}px`,
108
- top: `${ripple.y}px`,
109
- width: `${ripple.size}px`,
110
- height: `${ripple.size}px`,
111
- background: props.color ?? 'currentColor',
112
- }}
113
- />
114
- )}
115
- </For>
116
- </span>
117
106
  </span>
118
107
  )
119
108
  }
@@ -1,4 +1,5 @@
1
- // Pulsing placeholder for loading content — plain div, no primitive.
1
+ // Placeholder for loading content — plain div, no primitive. Pulses by default;
2
+ // opt into a sweeping shimmer band with `shimmer`.
2
3
  import type { JSX } from 'solid-js'
3
4
 
4
5
  import { cn } from '../lib/cn'
@@ -6,18 +7,29 @@ import { cn } from '../lib/cn'
6
7
  interface SkeletonProps {
7
8
  /** Size/shape the placeholder to match the content it stands in for, e.g. `"h-4 w-32 rounded-full"`. */
8
9
  class?: string
10
+ /**
11
+ * Use a light band sweeping across the placeholder instead of the default
12
+ * pulse. Pure CSS (`.skeleton-shimmer`), token-tinted, reduced-motion aware.
13
+ */
14
+ shimmer?: boolean
9
15
  }
10
16
 
11
17
  /**
12
- * Pulsing placeholder block for content that is still loading. Plain `div`,
13
- * no Kobalte primitive — size it via `class` to match the shape of the real
14
- * content (text line, avatar, card, etc.).
18
+ * Placeholder block for content that is still loading. Plain `div`, no Kobalte
19
+ * primitive — size it via `class` to match the shape of the real content (text
20
+ * line, avatar, card, etc.). Pulses by default; pass `shimmer` for a sweeping
21
+ * light band instead.
15
22
  *
16
23
  * @example
17
24
  * ```tsx
18
25
  * <Skeleton class="h-4 w-48" />
26
+ * <Skeleton shimmer class="h-4 w-48" /> // sweeping band instead of pulse
19
27
  * ```
20
28
  */
21
29
  export function Skeleton(props: SkeletonProps): JSX.Element {
22
- return <div class={cn('animate-pulse rounded-md bg-muted', props.class)} />
30
+ return (
31
+ <div
32
+ class={cn('rounded-md bg-muted', props.shimmer ? 'skeleton-shimmer' : 'animate-pulse', props.class)}
33
+ />
34
+ )
23
35
  }
@@ -5,6 +5,7 @@ import { createSignal, For, Show } from 'solid-js'
5
5
 
6
6
  import { cn } from '../lib/cn'
7
7
  import { animate, motionReduced } from '../lib/motion'
8
+ import { spawnRipple } from './Ripple'
8
9
 
9
10
  /** A single action in a {@link SpeedDial} — an icon button with an accessible label. */
10
11
  export interface SpeedDialAction {
@@ -74,7 +75,8 @@ export function SpeedDial(props: SpeedDialProps): JSX.Element {
74
75
  aria-label={open() ? 'Close actions' : 'Open actions'}
75
76
  aria-expanded={open()}
76
77
  onClick={() => setOpen((v) => !v)}
77
- class="flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform duration-150 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
78
+ onPointerDown={(event) => spawnRipple(event.currentTarget, event, { opacity: 0.35 })}
79
+ class="relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full bg-primary text-primary-foreground shadow-lg transition-transform duration-150 active:scale-[0.97] focus:outline-none focus:ring-2 focus:ring-ring"
78
80
  >
79
81
  <span class={cn('inline-flex transition-transform duration-150', open() && 'rotate-45')}>
80
82
  {props.icon ?? <Plus class="h-6 w-6" />}
@@ -2,7 +2,8 @@
2
2
  // element, like a light hovering over the surface. This is a plain CSS
3
3
  // follow (the overlay's `background` is written directly on pointermove)
4
4
  // rather than a Motion tween: a radial-gradient position update has no
5
- // transform to spring, so there's nothing for Motion to add here. Only the
5
+ // transform to spring, so there's nothing for an animation engine to add.
6
+ // Engine-free, which lets <Card spotlight> bake it in for free. Only the
6
7
  // fade in/out on enter/leave uses a CSS `transition: opacity`. No-op (no
7
8
  // glow, children still render) under reduced motion.
8
9
  import { onCleanup, onMount, type JSX } from 'solid-js'
@@ -19,11 +20,56 @@ export interface SpotlightProps {
19
20
  class?: string
20
21
  }
21
22
 
23
+ /**
24
+ * Appends a cursor-following radial-glow overlay inside `el` (which must be
25
+ * `position: relative; overflow: hidden`) and binds the pointer handlers.
26
+ * Returns a cleanup that unbinds and removes the overlay. Engine-free; no-op
27
+ * under reduced motion. This is the primitive behind {@link Spotlight} and
28
+ * `<Card spotlight>`.
29
+ */
30
+ export function attachSpotlight(el: HTMLElement, opts: { color?: string; size?: number } = {}): () => void {
31
+ if (motionReduced()) return () => {}
32
+
33
+ const overlay = document.createElement('span')
34
+ overlay.setAttribute('aria-hidden', 'true')
35
+ overlay.className = 'pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300'
36
+ el.appendChild(overlay)
37
+
38
+ const size = opts.size ?? 180
39
+ const color = opts.color ?? 'hsl(var(--primary))'
40
+
41
+ const paint = (x: number, y: number): void => {
42
+ overlay.style.background = `radial-gradient(${size}px circle at ${x}px ${y}px, color-mix(in srgb, ${color} 30%, transparent), transparent 70%)`
43
+ }
44
+ const handlePointerMove = (event: PointerEvent): void => {
45
+ const rect = el.getBoundingClientRect()
46
+ paint(event.clientX - rect.left, event.clientY - rect.top)
47
+ }
48
+ const handlePointerEnter = (): void => {
49
+ overlay.style.opacity = '1'
50
+ }
51
+ const handlePointerLeave = (): void => {
52
+ overlay.style.opacity = '0'
53
+ }
54
+
55
+ el.addEventListener('pointermove', handlePointerMove)
56
+ el.addEventListener('pointerenter', handlePointerEnter)
57
+ el.addEventListener('pointerleave', handlePointerLeave)
58
+ return () => {
59
+ el.removeEventListener('pointermove', handlePointerMove)
60
+ el.removeEventListener('pointerenter', handlePointerEnter)
61
+ el.removeEventListener('pointerleave', handlePointerLeave)
62
+ overlay.remove()
63
+ }
64
+ }
65
+
22
66
  /**
23
67
  * Wraps `children` in a `position: relative; overflow: hidden` layer with a
24
68
  * radial glow that tracks the cursor, fading in on pointerenter and out on
25
- * pointerleave. Children render above the glow. Respects
26
- * `prefers-reduced-motion` (renders children with no glow at all).
69
+ * pointerleave. Children render below the (pointer-transparent) glow.
70
+ * Respects `prefers-reduced-motion` (renders children with no glow at all).
71
+ *
72
+ * A4ui's own `Card` can do this without the wrapper — `<Card spotlight>`.
27
73
  *
28
74
  * @example
29
75
  * ```tsx
@@ -34,50 +80,15 @@ export interface SpotlightProps {
34
80
  */
35
81
  export function Spotlight(props: SpotlightProps): JSX.Element {
36
82
  let root!: HTMLDivElement
37
- let overlay!: HTMLSpanElement
38
83
 
39
84
  onMount(() => {
40
- if (motionReduced()) return
41
-
42
- const size = props.size ?? 180
43
- const color = props.color ?? 'hsl(var(--primary))'
44
-
45
- const paint = (x: number, y: number): void => {
46
- overlay.style.background = `radial-gradient(${size}px circle at ${x}px ${y}px, color-mix(in srgb, ${color} 30%, transparent), transparent 70%)`
47
- }
48
-
49
- const handlePointerMove = (event: PointerEvent): void => {
50
- const rect = root.getBoundingClientRect()
51
- paint(event.clientX - rect.left, event.clientY - rect.top)
52
- }
53
-
54
- const handlePointerEnter = (): void => {
55
- overlay.style.opacity = '1'
56
- }
57
-
58
- const handlePointerLeave = (): void => {
59
- overlay.style.opacity = '0'
60
- }
61
-
62
- root.addEventListener('pointermove', handlePointerMove)
63
- root.addEventListener('pointerenter', handlePointerEnter)
64
- root.addEventListener('pointerleave', handlePointerLeave)
65
-
66
- onCleanup(() => {
67
- root.removeEventListener('pointermove', handlePointerMove)
68
- root.removeEventListener('pointerenter', handlePointerEnter)
69
- root.removeEventListener('pointerleave', handlePointerLeave)
70
- })
85
+ const cleanup = attachSpotlight(root, { color: props.color, size: props.size })
86
+ onCleanup(cleanup)
71
87
  })
72
88
 
73
89
  return (
74
90
  <div ref={root} class={cn('relative overflow-hidden', props.class)}>
75
91
  {props.children}
76
- <span
77
- ref={overlay}
78
- aria-hidden="true"
79
- class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300"
80
- />
81
92
  </div>
82
93
  )
83
94
  }
@@ -1,12 +1,12 @@
1
1
  // TiltCard — tilts its wrapped content in 3D toward the cursor on hover, like
2
- // a card catching the light. The outer element sets the CSS perspective; the
3
- // inner element (which actually rotates) gets `transform-style: preserve-3d`
4
- // so nested content keeps its own depth. Motion's `animate` drives the
5
- // rotate/scale as a spring. No-op (static) under reduced motion.
2
+ // a card catching the light. The tilt is a direct transform smoothed by a CSS
3
+ // transition (engine-free no `motion` dependency), which keeps it cheap
4
+ // enough for <Card tilt> to bake in without growing every Card consumer's
5
+ // bundle. No-op (static) under reduced motion.
6
6
  import { onCleanup, onMount, type JSX } from 'solid-js'
7
7
 
8
8
  import { cn } from '../lib/cn'
9
- import { animate, motionReduced } from '../lib/motion'
9
+ import { motionReduced } from '../lib/motion'
10
10
 
11
11
  export interface TiltCardProps {
12
12
  children: JSX.Element
@@ -15,12 +15,54 @@ export interface TiltCardProps {
15
15
  class?: string
16
16
  }
17
17
 
18
+ /**
19
+ * Binds a cursor-following 3D tilt: pointer moves over `listenEl` rotate
20
+ * `animEl` toward the cursor (clamped to `max` degrees) with a slight lift,
21
+ * easing back flat on pointerleave. Returns a cleanup that unbinds and clears
22
+ * the transform. Engine-free (CSS transitions); no-op under reduced motion.
23
+ * This is the primitive behind {@link TiltCard} and `<Card tilt>` — pass the
24
+ * same element twice to tilt the element the pointer is over.
25
+ */
26
+ export function attachTilt(
27
+ listenEl: HTMLElement,
28
+ animEl: HTMLElement,
29
+ opts: { max?: number } = {},
30
+ ): () => void {
31
+ if (motionReduced()) return () => {}
32
+ animEl.style.willChange = 'transform'
33
+
34
+ const move = (event: PointerEvent): void => {
35
+ const rect = listenEl.getBoundingClientRect()
36
+ const nx = (event.clientX - rect.left) / rect.width - 0.5
37
+ const ny = (event.clientY - rect.top) / rect.height - 0.5
38
+ const max = opts.max ?? 10
39
+ animEl.style.transition = 'transform 0.15s ease-out'
40
+ animEl.style.transform = `perspective(800px) rotateX(${(ny * -2 * max).toFixed(2)}deg) rotateY(${(nx * 2 * max).toFixed(2)}deg) scale(1.02)`
41
+ }
42
+ const leave = (): void => {
43
+ animEl.style.transition = 'transform 0.4s ease'
44
+ animEl.style.transform = 'perspective(800px) rotateX(0deg) rotateY(0deg) scale(1)'
45
+ }
46
+
47
+ listenEl.addEventListener('pointermove', move)
48
+ listenEl.addEventListener('pointerleave', leave)
49
+ return () => {
50
+ listenEl.removeEventListener('pointermove', move)
51
+ listenEl.removeEventListener('pointerleave', leave)
52
+ animEl.style.transform = ''
53
+ animEl.style.transition = ''
54
+ animEl.style.willChange = ''
55
+ }
56
+ }
57
+
18
58
  /**
19
59
  * Wraps `children` in a card that tilts in 3D toward the cursor: as the
20
60
  * pointer moves within its bounds, the card rotates on both axes (clamped to
21
- * `max` degrees) and lifts slightly, springing back flat on pointerleave.
61
+ * `max` degrees) and lifts slightly, easing back flat on pointerleave.
22
62
  * Respects `prefers-reduced-motion` (renders static).
23
63
  *
64
+ * A4ui's own `Card` can do this without the wrapper — `<Card tilt>`.
65
+ *
24
66
  * @example
25
67
  * ```tsx
26
68
  * <TiltCard max={12}>
@@ -33,46 +75,13 @@ export function TiltCard(props: TiltCardProps): JSX.Element {
33
75
  let inner!: HTMLDivElement
34
76
 
35
77
  onMount(() => {
36
- if (motionReduced()) return
37
-
38
- let controls: ReturnType<typeof animate> | undefined
39
-
40
- const handlePointerMove = (event: PointerEvent): void => {
41
- const rect = root.getBoundingClientRect()
42
- const nx = (event.clientX - rect.left) / rect.width - 0.5
43
- const ny = (event.clientY - rect.top) / rect.height - 0.5
44
- const max = props.max ?? 10
45
-
46
- controls?.stop()
47
- controls = animate(
48
- inner,
49
- { rotateX: ny * -2 * max, rotateY: nx * 2 * max, scale: 1.02 },
50
- { type: 'spring', stiffness: 300, damping: 20 },
51
- )
52
- }
53
-
54
- const handlePointerLeave = (): void => {
55
- controls?.stop()
56
- controls = animate(
57
- inner,
58
- { rotateX: 0, rotateY: 0, scale: 1 },
59
- { type: 'spring', stiffness: 300, damping: 20 },
60
- )
61
- }
62
-
63
- root.addEventListener('pointermove', handlePointerMove)
64
- root.addEventListener('pointerleave', handlePointerLeave)
65
-
66
- onCleanup(() => {
67
- root.removeEventListener('pointermove', handlePointerMove)
68
- root.removeEventListener('pointerleave', handlePointerLeave)
69
- controls?.stop()
70
- })
78
+ const cleanup = attachTilt(root, inner, { max: props.max })
79
+ onCleanup(cleanup)
71
80
  })
72
81
 
73
82
  return (
74
83
  <div ref={root} class={cn('inline-block', props.class)} style={{ perspective: '800px' }}>
75
- <div ref={inner} class="will-change-transform" style={{ 'transform-style': 'preserve-3d' }}>
84
+ <div ref={inner} style={{ 'transform-style': 'preserve-3d' }}>
76
85
  {props.children}
77
86
  </div>
78
87
  </div>