@a4ui/core 0.15.0 → 0.16.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.
@@ -7,6 +7,12 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
7
7
  class?: string;
8
8
  /** Defaults to "button" so action buttons inside a form never submit it by accident. */
9
9
  type?: 'button' | 'submit' | 'reset';
10
+ /**
11
+ * Material-style click ripple from the press position. Engine-free (Web
12
+ * Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
13
+ * costs nothing when off.
14
+ */
15
+ ripple?: boolean;
10
16
  }
11
17
  /**
12
18
  * Base button primitive: a plain `<button>` with A4ui's variants, focus ring,
@@ -15,6 +21,7 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
15
21
  * @example
16
22
  * ```tsx
17
23
  * <Button variant="outline" onClick={() => save()}>Save</Button>
24
+ * <Button ripple onClick={() => save()}>Save</Button> // with a click ripple
18
25
  * ```
19
26
  */
20
27
  export declare function Button(props: ButtonProps): JSX.Element;
package/dist/ui/Card.d.ts CHANGED
@@ -10,15 +10,29 @@ interface CardProps extends DivProps {
10
10
  * for glass cards so the look is uniform; pass `glow={false}` to opt out.
11
11
  */
12
12
  glow?: boolean;
13
+ /**
14
+ * 3D tilt toward the cursor on hover — the same primitive as `<TiltCard>`,
15
+ * baked in ({@link attachTilt}). Engine-free and reduced-motion aware.
16
+ */
17
+ tilt?: boolean;
18
+ /**
19
+ * Soft radial glow following the cursor inside the card — the same primitive
20
+ * as `<Spotlight>`, baked in ({@link attachSpotlight}). Engine-free and
21
+ * reduced-motion aware.
22
+ */
23
+ spotlight?: boolean;
13
24
  }
14
25
  /**
15
26
  * Container surface for grouping related content, with an optional frosted
16
27
  * "space glass" look. Compose with {@link CardHeader}, {@link CardTitle}, and
17
28
  * {@link CardContent}.
18
29
  *
30
+ * `tilt` and `spotlight` bake in the cursor interactions from the Motion
31
+ * category — no wrapper components needed.
32
+ *
19
33
  * @example
20
34
  * ```tsx
21
- * <Card glass>
35
+ * <Card glass tilt spotlight>
22
36
  * <CardHeader>
23
37
  * <CardTitle>Usage</CardTitle>
24
38
  * </CardHeader>
@@ -7,6 +7,17 @@ export interface RippleProps {
7
7
  opacity?: number;
8
8
  class?: string;
9
9
  }
10
+ /**
11
+ * Spawns one Material-style ripple inside `container` at the pointer's
12
+ * position, expanding to cover the container and fading out, then removes
13
+ * itself. The container must be `position: relative; overflow: hidden`.
14
+ * Engine-free (Web Animations API); no-op under reduced motion. This is the
15
+ * primitive behind {@link Ripple} and `<Button ripple>`.
16
+ */
17
+ export declare function spawnRipple(container: HTMLElement, event: PointerEvent, opts?: {
18
+ color?: string;
19
+ opacity?: number;
20
+ }): void;
10
21
  /**
11
22
  * Wraps `children` in a `position: relative; overflow: hidden` layer that
12
23
  * spawns a Material-style ripple — a circle centered on the pointer's down
@@ -15,10 +26,12 @@ export interface RippleProps {
15
26
  * itself once its animation finishes. A no-op under reduced motion: children
16
27
  * still render, but no ripple ever spawns.
17
28
  *
29
+ * Buttons don't need the wrapper — use `<Button ripple>` instead.
30
+ *
18
31
  * @example
19
32
  * ```tsx
20
33
  * <Ripple color="white" opacity={0.25}>
21
- * <button class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</button>
34
+ * <div class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</div>
22
35
  * </Ripple>
23
36
  * ```
24
37
  */
@@ -7,11 +7,24 @@ export interface SpotlightProps {
7
7
  size?: number;
8
8
  class?: string;
9
9
  }
10
+ /**
11
+ * Appends a cursor-following radial-glow overlay inside `el` (which must be
12
+ * `position: relative; overflow: hidden`) and binds the pointer handlers.
13
+ * Returns a cleanup that unbinds and removes the overlay. Engine-free; no-op
14
+ * under reduced motion. This is the primitive behind {@link Spotlight} and
15
+ * `<Card spotlight>`.
16
+ */
17
+ export declare function attachSpotlight(el: HTMLElement, opts?: {
18
+ color?: string;
19
+ size?: number;
20
+ }): () => void;
10
21
  /**
11
22
  * Wraps `children` in a `position: relative; overflow: hidden` layer with a
12
23
  * radial glow that tracks the cursor, fading in on pointerenter and out on
13
- * pointerleave. Children render above the glow. Respects
14
- * `prefers-reduced-motion` (renders children with no glow at all).
24
+ * pointerleave. Children render below the (pointer-transparent) glow.
25
+ * Respects `prefers-reduced-motion` (renders children with no glow at all).
26
+ *
27
+ * A4ui's own `Card` can do this without the wrapper — `<Card spotlight>`.
15
28
  *
16
29
  * @example
17
30
  * ```tsx
@@ -5,12 +5,25 @@ export interface TiltCardProps {
5
5
  max?: number;
6
6
  class?: string;
7
7
  }
8
+ /**
9
+ * Binds a cursor-following 3D tilt: pointer moves over `listenEl` rotate
10
+ * `animEl` toward the cursor (clamped to `max` degrees) with a slight lift,
11
+ * easing back flat on pointerleave. Returns a cleanup that unbinds and clears
12
+ * the transform. Engine-free (CSS transitions); no-op under reduced motion.
13
+ * This is the primitive behind {@link TiltCard} and `<Card tilt>` — pass the
14
+ * same element twice to tilt the element the pointer is over.
15
+ */
16
+ export declare function attachTilt(listenEl: HTMLElement, animEl: HTMLElement, opts?: {
17
+ max?: number;
18
+ }): () => void;
8
19
  /**
9
20
  * Wraps `children` in a card that tilts in 3D toward the cursor: as the
10
21
  * pointer moves within its bounds, the card rotates on both axes (clamped to
11
- * `max` degrees) and lifts slightly, springing back flat on pointerleave.
22
+ * `max` degrees) and lifts slightly, easing back flat on pointerleave.
12
23
  * Respects `prefers-reduced-motion` (renders static).
13
24
  *
25
+ * A4ui's own `Card` can do this without the wrapper — `<Card tilt>`.
26
+ *
14
27
  * @example
15
28
  * ```tsx
16
29
  * <TiltCard max={12}>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
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.15.0'
11
+ export const A4UI_VERSION = '0.16.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -150,10 +150,10 @@ export { MultiStateBadge, type MultiStateBadgeProps, type BadgeState } from './u
150
150
  export { NowPlaying, type NowPlayingProps } from './ui/NowPlaying'
151
151
  export { Expandable, type ExpandableProps } from './ui/Expandable'
152
152
  export { Typewriter, type TypewriterProps } from './ui/Typewriter'
153
- export { Ripple, type RippleProps } from './ui/Ripple'
153
+ export { Ripple, spawnRipple, type RippleProps } from './ui/Ripple'
154
154
  export { Magnetic, type MagneticProps } from './ui/Magnetic'
155
- export { TiltCard, type TiltCardProps } from './ui/TiltCard'
156
- export { Spotlight, type SpotlightProps } from './ui/Spotlight'
155
+ export { TiltCard, attachTilt, type TiltCardProps } from './ui/TiltCard'
156
+ export { Spotlight, attachSpotlight, type SpotlightProps } from './ui/Spotlight'
157
157
  export { ScrollProgress, type ScrollProgressProps } from './ui/ScrollProgress'
158
158
  export { GradientText, type GradientTextProps } from './ui/GradientText'
159
159
  export { flyToCart, type FlyToCartOptions } from './lib/flyToCart'
package/src/ui/Button.tsx CHANGED
@@ -2,6 +2,7 @@ import type { JSX, ParentProps } from 'solid-js'
2
2
  import { splitProps } from 'solid-js'
3
3
 
4
4
  import { cn } from '../lib/cn'
5
+ import { spawnRipple } from './Ripple'
5
6
 
6
7
  /** Visual style of a {@link Button}. Defaults to `'primary'`. */
7
8
  export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
@@ -24,6 +25,12 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
24
25
  class?: string
25
26
  /** Defaults to "button" so action buttons inside a form never submit it by accident. */
26
27
  type?: 'button' | 'submit' | 'reset'
28
+ /**
29
+ * Material-style click ripple from the press position. Engine-free (Web
30
+ * Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
31
+ * costs nothing when off.
32
+ */
33
+ ripple?: boolean
27
34
  }
28
35
 
29
36
  /**
@@ -33,14 +40,29 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
33
40
  * @example
34
41
  * ```tsx
35
42
  * <Button variant="outline" onClick={() => save()}>Save</Button>
43
+ * <Button ripple onClick={() => save()}>Save</Button> // with a click ripple
36
44
  * ```
37
45
  */
38
46
  export function Button(props: ButtonProps): JSX.Element {
39
- const [local, rest] = splitProps(props, ['variant', 'class', 'type', 'children'])
47
+ const [local, rest] = splitProps(props, ['variant', 'class', 'type', 'children', 'ripple', 'onPointerDown'])
48
+
49
+ const handlePointerDown: JSX.EventHandler<HTMLButtonElement, PointerEvent> = (event) => {
50
+ if (local.ripple) spawnRipple(event.currentTarget, event, { opacity: 0.35 })
51
+ const user = local.onPointerDown
52
+ if (typeof user === 'function') user(event)
53
+ else if (user) user[0](user[1], event)
54
+ }
55
+
40
56
  return (
41
57
  <button
42
58
  type={local.type ?? 'button'}
43
- class={cn(BUTTON_BASE, VARIANT_CLASSES[local.variant ?? 'primary'], local.class)}
59
+ class={cn(
60
+ BUTTON_BASE,
61
+ VARIANT_CLASSES[local.variant ?? 'primary'],
62
+ local.ripple && 'relative overflow-hidden',
63
+ local.class,
64
+ )}
65
+ onPointerDown={handlePointerDown}
44
66
  {...rest}
45
67
  >
46
68
  {local.children}
package/src/ui/Card.tsx CHANGED
@@ -1,8 +1,10 @@
1
1
  // Card + 4 sub-parts (no CardFooter/CardDescription — add if needed).
2
2
  import type { JSX, ParentProps } from 'solid-js'
3
- import { splitProps } from 'solid-js'
3
+ import { onCleanup, onMount, splitProps } from 'solid-js'
4
4
 
5
5
  import { cn } from '../lib/cn'
6
+ import { attachSpotlight } from './Spotlight'
7
+ import { attachTilt } from './TiltCard'
6
8
 
7
9
  interface DivProps extends ParentProps {
8
10
  class?: string
@@ -16,6 +18,17 @@ interface CardProps extends DivProps {
16
18
  * for glass cards so the look is uniform; pass `glow={false}` to opt out.
17
19
  */
18
20
  glow?: boolean
21
+ /**
22
+ * 3D tilt toward the cursor on hover — the same primitive as `<TiltCard>`,
23
+ * baked in ({@link attachTilt}). Engine-free and reduced-motion aware.
24
+ */
25
+ tilt?: boolean
26
+ /**
27
+ * Soft radial glow following the cursor inside the card — the same primitive
28
+ * as `<Spotlight>`, baked in ({@link attachSpotlight}). Engine-free and
29
+ * reduced-motion aware.
30
+ */
31
+ spotlight?: boolean
19
32
  }
20
33
 
21
34
  /**
@@ -23,9 +36,12 @@ interface CardProps extends DivProps {
23
36
  * "space glass" look. Compose with {@link CardHeader}, {@link CardTitle}, and
24
37
  * {@link CardContent}.
25
38
  *
39
+ * `tilt` and `spotlight` bake in the cursor interactions from the Motion
40
+ * category — no wrapper components needed.
41
+ *
26
42
  * @example
27
43
  * ```tsx
28
- * <Card glass>
44
+ * <Card glass tilt spotlight>
29
45
  * <CardHeader>
30
46
  * <CardTitle>Usage</CardTitle>
31
47
  * </CardHeader>
@@ -34,16 +50,29 @@ interface CardProps extends DivProps {
34
50
  * ```
35
51
  */
36
52
  export function Card(props: CardProps): JSX.Element {
37
- const [local, rest] = splitProps(props, ['class', 'children', 'glass', 'glow'])
53
+ const [local, rest] = splitProps(props, ['class', 'children', 'glass', 'glow', 'tilt', 'spotlight'])
38
54
  // Glow defaults to the card's glass state when not explicitly set.
39
55
  const showGlow = () => (local.glow ?? local.glass) === true
56
+
57
+ let el: HTMLDivElement | undefined
58
+ onMount(() => {
59
+ const cleanups = [
60
+ local.tilt && el ? attachTilt(el, el) : null,
61
+ local.spotlight && el ? attachSpotlight(el) : null,
62
+ ].filter((f): f is () => void => f !== null)
63
+ onCleanup(() => cleanups.forEach((f) => f()))
64
+ })
65
+
40
66
  return (
41
67
  <div
68
+ ref={el}
42
69
  class={cn(
43
70
  local.glass
44
71
  ? 'card rounded-xl text-card-foreground'
45
72
  : 'rounded-xl border border-border bg-card text-card-foreground shadow-sm',
46
73
  showGlow() && 'glow-edge',
74
+ local.spotlight && 'relative overflow-hidden',
75
+ local.tilt && 'will-change-transform',
47
76
  local.class,
48
77
  )}
49
78
  {...rest}
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
  }
@@ -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
  }