@a4ui/core 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -30
- package/dist/elements.css +6 -0
- package/dist/full.css +6 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +1650 -1505
- package/dist/ui/GradientText.d.ts +25 -0
- package/dist/ui/Magnetic.d.ts +22 -0
- package/dist/ui/ScrollProgress.d.ts +20 -0
- package/dist/ui/Spotlight.d.ts +23 -0
- package/dist/ui/TiltCard.d.ts +21 -0
- package/package.json +3 -1
- package/src/index.ts +6 -1
- package/src/ui/GradientText.tsx +69 -0
- package/src/ui/Magnetic.tsx +73 -0
- package/src/ui/ScrollProgress.tsx +61 -0
- package/src/ui/Spotlight.tsx +83 -0
- package/src/ui/TiltCard.tsx +80 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface GradientTextProps {
|
|
3
|
+
children: JSX.Element;
|
|
4
|
+
/** CSS gradient color stop at 0%. @default 'hsl(var(--primary))' */
|
|
5
|
+
from?: string;
|
|
6
|
+
/** CSS gradient color stop at 50%. @default 'hsl(var(--accent))' */
|
|
7
|
+
via?: string;
|
|
8
|
+
/** CSS gradient color stop at 100%. @default 'hsl(var(--primary))' */
|
|
9
|
+
to?: string;
|
|
10
|
+
/** Seconds per loop. @default 4 */
|
|
11
|
+
duration?: number;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Renders `children` clipped to a horizontal gradient whose position sweeps
|
|
16
|
+
* back and forth in a loop, for eye-catching headings. Respects
|
|
17
|
+
* `prefers-reduced-motion` by rendering a static gradient instead of
|
|
18
|
+
* animating.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <GradientText duration={6}>Sonora Precision</GradientText>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function GradientText(props: GradientTextProps): JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface MagneticProps {
|
|
3
|
+
children: JSX.Element;
|
|
4
|
+
/** Max px the element travels toward the cursor. @default 12 */
|
|
5
|
+
strength?: number;
|
|
6
|
+
class?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Wraps `children` in an element that's magnetically drawn toward the
|
|
10
|
+
* cursor: as the pointer moves within its bounds, the element translates a
|
|
11
|
+
* fraction of the way toward it (clamped to `strength` px), springing back
|
|
12
|
+
* to rest on pointerleave. Respects `prefers-reduced-motion` (renders
|
|
13
|
+
* static).
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <Magnetic strength={16}>
|
|
18
|
+
* <button class="rounded-full bg-primary px-6 py-3 text-primary-foreground">Hover me</button>
|
|
19
|
+
* </Magnetic>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export declare function Magnetic(props: MagneticProps): JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface ScrollProgressProps {
|
|
3
|
+
/** Bar height in px. @default 3 */
|
|
4
|
+
height?: number;
|
|
5
|
+
/** Bar color (CSS color). @default 'hsl(var(--primary))' */
|
|
6
|
+
color?: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Renders a fixed bar at the top of the viewport whose fill tracks how far
|
|
11
|
+
* the page has been scrolled. Subscribes to Motion's `scroll` on mount and
|
|
12
|
+
* scales the inner bar horizontally; respects `prefers-reduced-motion`
|
|
13
|
+
* (renders a static, empty bar instead).
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <ScrollProgress height={4} color="hsl(var(--accent))" />
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function ScrollProgress(props: ScrollProgressProps): JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface SpotlightProps {
|
|
3
|
+
children: JSX.Element;
|
|
4
|
+
/** Glow color (CSS color). @default 'hsl(var(--primary))' */
|
|
5
|
+
color?: string;
|
|
6
|
+
/** Glow radius in px. @default 180 */
|
|
7
|
+
size?: number;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Wraps `children` in a `position: relative; overflow: hidden` layer with a
|
|
12
|
+
* 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).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <Spotlight color="hsl(var(--primary))" size={220}>
|
|
19
|
+
* <div class="rounded-2xl border border-border p-8">Hover for glow</div>
|
|
20
|
+
* </Spotlight>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function Spotlight(props: SpotlightProps): JSX.Element;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface TiltCardProps {
|
|
3
|
+
children: JSX.Element;
|
|
4
|
+
/** Max tilt in degrees. @default 10 */
|
|
5
|
+
max?: number;
|
|
6
|
+
class?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Wraps `children` in a card that tilts in 3D toward the cursor: as the
|
|
10
|
+
* pointer moves within its bounds, the card rotates on both axes (clamped to
|
|
11
|
+
* `max` degrees) and lifts slightly, springing back flat on pointerleave.
|
|
12
|
+
* Respects `prefers-reduced-motion` (renders static).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <TiltCard max={12}>
|
|
17
|
+
* <div class="rounded-2xl border border-border bg-card p-6">Card content</div>
|
|
18
|
+
* </TiltCard>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export declare function TiltCard(props: TiltCardProps): JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a4ui/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
"test": "playwright test",
|
|
99
99
|
"test:ui": "playwright test --ui",
|
|
100
100
|
"test:a11y": "playwright test a11y --project=desktop",
|
|
101
|
+
"test:visual": "playwright test --config playwright.visual.config.ts",
|
|
101
102
|
"test:report": "playwright show-report",
|
|
102
103
|
"test:package": "node scripts/test-package.mjs",
|
|
103
104
|
"validate": "npm run typecheck && npm run lint && npm run format:check && npm run test:unit && npm run build && npm run test:package",
|
|
@@ -137,6 +138,7 @@
|
|
|
137
138
|
"husky": "^9.1.7",
|
|
138
139
|
"jsdom": "^29.1.1",
|
|
139
140
|
"lint-staged": "^16.4.0",
|
|
141
|
+
"marked": "^18.0.6",
|
|
140
142
|
"playwright-core": "^1.61.1",
|
|
141
143
|
"prettier": "^3.9.5",
|
|
142
144
|
"solid-element": "^1.9.2",
|
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.
|
|
11
|
+
export const A4UI_VERSION = '0.15.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -151,6 +151,11 @@ 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
153
|
export { Ripple, type RippleProps } from './ui/Ripple'
|
|
154
|
+
export { Magnetic, type MagneticProps } from './ui/Magnetic'
|
|
155
|
+
export { TiltCard, type TiltCardProps } from './ui/TiltCard'
|
|
156
|
+
export { Spotlight, type SpotlightProps } from './ui/Spotlight'
|
|
157
|
+
export { ScrollProgress, type ScrollProgressProps } from './ui/ScrollProgress'
|
|
158
|
+
export { GradientText, type GradientTextProps } from './ui/GradientText'
|
|
154
159
|
export { flyToCart, type FlyToCartOptions } from './lib/flyToCart'
|
|
155
160
|
|
|
156
161
|
// Layout (src/layout) — generic shell + backdrop + toggles. App-coupled pieces
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// GradientText — text with a smoothly animated multi-color gradient sweeping
|
|
2
|
+
// across the glyphs on loop. The sweep is a background-position animation
|
|
3
|
+
// clipped to the glyphs via `background-clip: text`, driven by Motion's
|
|
4
|
+
// `animate`. Falls back to a static (non-animated) gradient under reduced
|
|
5
|
+
// motion — still colorful, just not moving.
|
|
6
|
+
import { onCleanup, onMount, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
10
|
+
|
|
11
|
+
export interface GradientTextProps {
|
|
12
|
+
children: JSX.Element
|
|
13
|
+
/** CSS gradient color stop at 0%. @default 'hsl(var(--primary))' */
|
|
14
|
+
from?: string
|
|
15
|
+
/** CSS gradient color stop at 50%. @default 'hsl(var(--accent))' */
|
|
16
|
+
via?: string
|
|
17
|
+
/** CSS gradient color stop at 100%. @default 'hsl(var(--primary))' */
|
|
18
|
+
to?: string
|
|
19
|
+
/** Seconds per loop. @default 4 */
|
|
20
|
+
duration?: number
|
|
21
|
+
class?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Renders `children` clipped to a horizontal gradient whose position sweeps
|
|
26
|
+
* back and forth in a loop, for eye-catching headings. Respects
|
|
27
|
+
* `prefers-reduced-motion` by rendering a static gradient instead of
|
|
28
|
+
* animating.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <GradientText duration={6}>Sonora Precision</GradientText>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function GradientText(props: GradientTextProps): JSX.Element {
|
|
36
|
+
let el!: HTMLSpanElement
|
|
37
|
+
let controls: { stop: () => void } | undefined
|
|
38
|
+
|
|
39
|
+
onMount(() => {
|
|
40
|
+
if (motionReduced()) return
|
|
41
|
+
|
|
42
|
+
controls = animate(
|
|
43
|
+
el,
|
|
44
|
+
{ backgroundPosition: ['0% 0%', '200% 0%'] },
|
|
45
|
+
{ duration: props.duration ?? 4, ease: 'linear', repeat: Infinity },
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
onCleanup(() => controls?.stop())
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<span
|
|
53
|
+
ref={el}
|
|
54
|
+
class={cn('inline-block font-bold', props.class)}
|
|
55
|
+
style={{
|
|
56
|
+
'background-image': `linear-gradient(90deg, ${props.from ?? 'hsl(var(--primary))'} 0%, ${
|
|
57
|
+
props.via ?? 'hsl(var(--accent))'
|
|
58
|
+
} 50%, ${props.to ?? 'hsl(var(--primary))'} 100%)`,
|
|
59
|
+
'background-size': '200% 100%',
|
|
60
|
+
'-webkit-background-clip': 'text',
|
|
61
|
+
'background-clip': 'text',
|
|
62
|
+
'-webkit-text-fill-color': 'transparent',
|
|
63
|
+
color: 'transparent',
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{props.children}
|
|
67
|
+
</span>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Magnetic — pulls its wrapped content toward the cursor while the pointer
|
|
2
|
+
// hovers over it, and springs back to rest on pointerleave. Motion's `animate`
|
|
3
|
+
// drives the translate (a spring, so repeated pointermoves smoothly retarget
|
|
4
|
+
// mid-flight rather than snapping). No-op (static) under reduced motion.
|
|
5
|
+
import { onCleanup, onMount, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface MagneticProps {
|
|
11
|
+
children: JSX.Element
|
|
12
|
+
/** Max px the element travels toward the cursor. @default 12 */
|
|
13
|
+
strength?: number
|
|
14
|
+
class?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps `children` in an element that's magnetically drawn toward the
|
|
19
|
+
* cursor: as the pointer moves within its bounds, the element translates a
|
|
20
|
+
* fraction of the way toward it (clamped to `strength` px), springing back
|
|
21
|
+
* to rest on pointerleave. Respects `prefers-reduced-motion` (renders
|
|
22
|
+
* static).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <Magnetic strength={16}>
|
|
27
|
+
* <button class="rounded-full bg-primary px-6 py-3 text-primary-foreground">Hover me</button>
|
|
28
|
+
* </Magnetic>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function Magnetic(props: MagneticProps): JSX.Element {
|
|
32
|
+
let root!: HTMLDivElement
|
|
33
|
+
|
|
34
|
+
onMount(() => {
|
|
35
|
+
if (motionReduced()) return
|
|
36
|
+
|
|
37
|
+
let controls: ReturnType<typeof animate> | undefined
|
|
38
|
+
|
|
39
|
+
const handlePointerMove = (event: PointerEvent): void => {
|
|
40
|
+
const rect = root.getBoundingClientRect()
|
|
41
|
+
const nx = (event.clientX - (rect.left + rect.width / 2)) / (rect.width / 2)
|
|
42
|
+
const ny = (event.clientY - (rect.top + rect.height / 2)) / (rect.height / 2)
|
|
43
|
+
const strength = props.strength ?? 12
|
|
44
|
+
|
|
45
|
+
controls?.stop()
|
|
46
|
+
controls = animate(
|
|
47
|
+
root,
|
|
48
|
+
{ x: nx * strength, y: ny * strength },
|
|
49
|
+
{ type: 'spring', stiffness: 300, damping: 20 },
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const handlePointerLeave = (): void => {
|
|
54
|
+
controls?.stop()
|
|
55
|
+
controls = animate(root, { x: 0, y: 0 }, { type: 'spring', stiffness: 300, damping: 20 })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
root.addEventListener('pointermove', handlePointerMove)
|
|
59
|
+
root.addEventListener('pointerleave', handlePointerLeave)
|
|
60
|
+
|
|
61
|
+
onCleanup(() => {
|
|
62
|
+
root.removeEventListener('pointermove', handlePointerMove)
|
|
63
|
+
root.removeEventListener('pointerleave', handlePointerLeave)
|
|
64
|
+
controls?.stop()
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div ref={root} class={cn('inline-block will-change-transform', props.class)}>
|
|
70
|
+
{props.children}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ScrollProgress — a thin bar pinned to the top of the viewport that fills
|
|
2
|
+
// left-to-right as the page scrolls, a common reading-progress indicator.
|
|
3
|
+
// Driven by Motion's `scroll` progress callback (0..1). Renders a static,
|
|
4
|
+
// unfilled bar under reduced motion (no scroll subscription).
|
|
5
|
+
import { onCleanup, onMount, type JSX } from 'solid-js'
|
|
6
|
+
|
|
7
|
+
import { cn } from '../lib/cn'
|
|
8
|
+
import { motionReduced, scroll } from '../lib/motion'
|
|
9
|
+
|
|
10
|
+
export interface ScrollProgressProps {
|
|
11
|
+
/** Bar height in px. @default 3 */
|
|
12
|
+
height?: number
|
|
13
|
+
/** Bar color (CSS color). @default 'hsl(var(--primary))' */
|
|
14
|
+
color?: string
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Renders a fixed bar at the top of the viewport whose fill tracks how far
|
|
20
|
+
* the page has been scrolled. Subscribes to Motion's `scroll` on mount and
|
|
21
|
+
* scales the inner bar horizontally; respects `prefers-reduced-motion`
|
|
22
|
+
* (renders a static, empty bar instead).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <ScrollProgress height={4} color="hsl(var(--accent))" />
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function ScrollProgress(props: ScrollProgressProps): JSX.Element {
|
|
30
|
+
let bar!: HTMLDivElement
|
|
31
|
+
|
|
32
|
+
onMount(() => {
|
|
33
|
+
bar.style.transform = 'scaleX(0)'
|
|
34
|
+
|
|
35
|
+
if (motionReduced()) return
|
|
36
|
+
|
|
37
|
+
const stop = scroll((progress: number) => {
|
|
38
|
+
bar.style.transform = `scaleX(${progress})`
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
onCleanup(() => stop())
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
aria-hidden="true"
|
|
47
|
+
class={cn('fixed top-0 left-0 z-[9999] w-full', props.class)}
|
|
48
|
+
style={{ height: `${props.height ?? 3}px` }}
|
|
49
|
+
>
|
|
50
|
+
<div
|
|
51
|
+
ref={bar}
|
|
52
|
+
class="origin-left will-change-transform"
|
|
53
|
+
style={{
|
|
54
|
+
height: '100%',
|
|
55
|
+
width: '100%',
|
|
56
|
+
'background-color': props.color ?? 'hsl(var(--primary))',
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Spotlight — a soft radial glow that follows the cursor inside the wrapped
|
|
2
|
+
// element, like a light hovering over the surface. This is a plain CSS
|
|
3
|
+
// follow (the overlay's `background` is written directly on pointermove)
|
|
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
|
|
6
|
+
// fade in/out on enter/leave uses a CSS `transition: opacity`. No-op (no
|
|
7
|
+
// glow, children still render) under reduced motion.
|
|
8
|
+
import { onCleanup, onMount, type JSX } from 'solid-js'
|
|
9
|
+
|
|
10
|
+
import { cn } from '../lib/cn'
|
|
11
|
+
import { motionReduced } from '../lib/motion'
|
|
12
|
+
|
|
13
|
+
export interface SpotlightProps {
|
|
14
|
+
children: JSX.Element
|
|
15
|
+
/** Glow color (CSS color). @default 'hsl(var(--primary))' */
|
|
16
|
+
color?: string
|
|
17
|
+
/** Glow radius in px. @default 180 */
|
|
18
|
+
size?: number
|
|
19
|
+
class?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wraps `children` in a `position: relative; overflow: hidden` layer with a
|
|
24
|
+
* 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).
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* <Spotlight color="hsl(var(--primary))" size={220}>
|
|
31
|
+
* <div class="rounded-2xl border border-border p-8">Hover for glow</div>
|
|
32
|
+
* </Spotlight>
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function Spotlight(props: SpotlightProps): JSX.Element {
|
|
36
|
+
let root!: HTMLDivElement
|
|
37
|
+
let overlay!: HTMLSpanElement
|
|
38
|
+
|
|
39
|
+
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
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div ref={root} class={cn('relative overflow-hidden', props.class)}>
|
|
75
|
+
{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
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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.
|
|
6
|
+
import { onCleanup, onMount, type JSX } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { animate, motionReduced } from '../lib/motion'
|
|
10
|
+
|
|
11
|
+
export interface TiltCardProps {
|
|
12
|
+
children: JSX.Element
|
|
13
|
+
/** Max tilt in degrees. @default 10 */
|
|
14
|
+
max?: number
|
|
15
|
+
class?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wraps `children` in a card that tilts in 3D toward the cursor: as the
|
|
20
|
+
* pointer moves within its bounds, the card rotates on both axes (clamped to
|
|
21
|
+
* `max` degrees) and lifts slightly, springing back flat on pointerleave.
|
|
22
|
+
* Respects `prefers-reduced-motion` (renders static).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```tsx
|
|
26
|
+
* <TiltCard max={12}>
|
|
27
|
+
* <div class="rounded-2xl border border-border bg-card p-6">Card content</div>
|
|
28
|
+
* </TiltCard>
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function TiltCard(props: TiltCardProps): JSX.Element {
|
|
32
|
+
let root!: HTMLDivElement
|
|
33
|
+
let inner!: HTMLDivElement
|
|
34
|
+
|
|
35
|
+
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
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<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' }}>
|
|
76
|
+
{props.children}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|