@a4ui/core 0.12.0 → 0.13.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,141 @@
1
+ // A destructive/important-action button that requires a press-and-HOLD (not a
2
+ // single click) to confirm — the fill sweeping across the button doubles as a
3
+ // "how much longer" progress affordance, guarding against accidental taps.
4
+ import type { JSX } from 'solid-js'
5
+ import { createSignal, onCleanup } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { animate, motionReduced } from '../lib/motion'
9
+
10
+ export interface HoldToConfirmProps {
11
+ onConfirm: () => void
12
+ /** Button label. @default 'Hold to confirm' */
13
+ label?: string
14
+ /** How long to hold, in ms. @default 1200 */
15
+ holdMs?: number
16
+ disabled?: boolean
17
+ class?: string
18
+ }
19
+
20
+ /**
21
+ * Press-and-hold button for destructive/important actions: `onConfirm` only
22
+ * fires once the pointer (or Space/Enter) has been held down for `holdMs`.
23
+ * A fill layer sweeps left-to-right as visual progress; releasing early stops
24
+ * and resets it. Under reduced motion the hold is still required (gated by a
25
+ * plain timer instead of the tracked animation), but the fill jumps instead
26
+ * of sweeping and the success flourish is skipped.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <HoldToConfirm label="Hold to delete" holdMs={1500} onConfirm={() => deleteItem(id)} />
31
+ * ```
32
+ */
33
+ export function HoldToConfirm(props: HoldToConfirmProps): JSX.Element {
34
+ const [holding, setHolding] = createSignal(false)
35
+
36
+ let buttonEl: HTMLButtonElement | undefined
37
+ let fillEl: HTMLSpanElement | undefined
38
+ let controls: ReturnType<typeof animate> | undefined
39
+ let timer: ReturnType<typeof setTimeout> | undefined
40
+ let active = false
41
+
42
+ const holdMs = () => props.holdMs ?? 1200
43
+
44
+ const resetFill = (durationSec: number) => {
45
+ if (!fillEl) return
46
+ if (motionReduced()) {
47
+ fillEl.style.transform = 'scaleX(0)'
48
+ return
49
+ }
50
+ animate(fillEl, { scaleX: 0 }, { duration: durationSec, ease: 'easeOut' })
51
+ }
52
+
53
+ const succeed = () => {
54
+ active = false
55
+ setHolding(false)
56
+ props.onConfirm()
57
+ if (!motionReduced() && buttonEl) {
58
+ animate(buttonEl, { scale: [1, 1.04, 1] }, { duration: 0.3, ease: 'easeOut' })
59
+ }
60
+ resetFill(0.2)
61
+ }
62
+
63
+ const cancel = () => {
64
+ if (!active) return
65
+ active = false
66
+ setHolding(false)
67
+ controls?.stop()
68
+ if (timer !== undefined) {
69
+ clearTimeout(timer)
70
+ timer = undefined
71
+ }
72
+ resetFill(0.15)
73
+ }
74
+
75
+ const start = (event: PointerEvent | KeyboardEvent) => {
76
+ if (props.disabled || active) return
77
+ event.preventDefault()
78
+ active = true
79
+ setHolding(true)
80
+
81
+ if (motionReduced()) {
82
+ if (fillEl) fillEl.style.transform = 'scaleX(1)'
83
+ timer = setTimeout(() => {
84
+ if (active) succeed()
85
+ }, holdMs())
86
+ return
87
+ }
88
+
89
+ if (!fillEl) return
90
+ controls = animate(fillEl, { scaleX: [0, 1] }, { duration: holdMs() / 1000, ease: 'linear' })
91
+ controls.finished
92
+ // eslint-disable-next-line solid/reactivity -- promise callback, not a tracked scope; setHolding runs fine here
93
+ .then(() => {
94
+ if (active) succeed()
95
+ })
96
+ .catch(() => {})
97
+ }
98
+
99
+ const onKeyDown = (event: KeyboardEvent) => {
100
+ if (event.key !== ' ' && event.key !== 'Enter') return
101
+ if (event.repeat) return
102
+ start(event)
103
+ }
104
+
105
+ const onKeyUp = (event: KeyboardEvent) => {
106
+ if (event.key !== ' ' && event.key !== 'Enter') return
107
+ cancel()
108
+ }
109
+
110
+ onCleanup(() => {
111
+ controls?.stop()
112
+ if (timer !== undefined) clearTimeout(timer)
113
+ })
114
+
115
+ return (
116
+ <button
117
+ ref={buttonEl}
118
+ type="button"
119
+ disabled={props.disabled}
120
+ aria-label={props.label ?? 'Hold to confirm'}
121
+ aria-busy={holding()}
122
+ class={cn(
123
+ 'relative overflow-hidden rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium disabled:pointer-events-none disabled:opacity-50',
124
+ props.class,
125
+ )}
126
+ onPointerDown={start}
127
+ onPointerUp={cancel}
128
+ onPointerLeave={cancel}
129
+ onKeyDown={onKeyDown}
130
+ onKeyUp={onKeyUp}
131
+ >
132
+ <span
133
+ aria-hidden="true"
134
+ ref={fillEl}
135
+ class="absolute inset-0 origin-left bg-primary/25"
136
+ style={{ transform: 'scaleX(0)' }}
137
+ />
138
+ <span class="relative z-10">{props.label ?? 'Hold to confirm'}</span>
139
+ </button>
140
+ )
141
+ }
@@ -0,0 +1,75 @@
1
+ // Three dots bouncing in a staggered wave — a lightweight indeterminate
2
+ // loading indicator. The bounce is pure CSS (keyframes injected once into the
3
+ // document head) so there's no per-frame JS cost; reduced motion swaps the
4
+ // bounce for a gentle opacity pulse.
5
+ import type { JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { motionReduced } from '../lib/motion'
9
+
10
+ export interface LoadingDotsProps {
11
+ /** Dot diameter in px. @default 8 */
12
+ size?: number
13
+ /** Optional accessible label. @default 'Loading' */
14
+ label?: string
15
+ class?: string
16
+ }
17
+
18
+ const STYLE_ID = 'a4ui-loading-dots-style'
19
+
20
+ function ensureStyleInjected(): void {
21
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
22
+ const style = document.createElement('style')
23
+ style.id = STYLE_ID
24
+ style.textContent = `
25
+ @keyframes a4-dot-bounce {
26
+ 0%, 100% { transform: translateY(0); }
27
+ 50% { transform: translateY(-60%); }
28
+ }
29
+ @keyframes a4-dot-pulse {
30
+ 0%, 100% { opacity: 1; }
31
+ 50% { opacity: 0.35; }
32
+ }
33
+ `
34
+ document.head.appendChild(style)
35
+ }
36
+
37
+ /**
38
+ * Three dots bouncing in a staggered wave — an inline, indeterminate loading
39
+ * indicator. Dots use `bg-current`, so color/size follow the ancestor's text
40
+ * color; diameter is set via `size`. Falls back to a gentle opacity pulse
41
+ * under reduced motion.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <LoadingDots label="Loading results" class="text-primary" />
46
+ * ```
47
+ */
48
+ export function LoadingDots(props: LoadingDotsProps): JSX.Element {
49
+ ensureStyleInjected()
50
+
51
+ const size = () => props.size ?? 8
52
+ const animationName = () => (motionReduced() ? 'a4-dot-pulse' : 'a4-dot-bounce')
53
+
54
+ const dotStyle = (delayMs: number): JSX.CSSProperties => ({
55
+ width: `${size()}px`,
56
+ height: `${size()}px`,
57
+ 'animation-name': animationName(),
58
+ 'animation-duration': '0.6s',
59
+ 'animation-iteration-count': 'infinite',
60
+ 'animation-timing-function': 'ease-in-out',
61
+ 'animation-delay': `${delayMs}ms`,
62
+ })
63
+
64
+ return (
65
+ <span
66
+ role="status"
67
+ aria-label={props.label ?? 'Loading'}
68
+ class={cn('inline-flex items-center gap-1 text-current', props.class)}
69
+ >
70
+ <span class="rounded-full bg-current" style={dotStyle(0)} />
71
+ <span class="rounded-full bg-current" style={dotStyle(120)} />
72
+ <span class="rounded-full bg-current" style={dotStyle(240)} />
73
+ </span>
74
+ )
75
+ }
@@ -0,0 +1,93 @@
1
+ // A status pill that smoothly morphs between idle/loading/success/error: the
2
+ // background/text color tweens via CSS `transition-colors` while the icon and
3
+ // label cross-fade, and each state change gets a spring "pop" via Motion
4
+ // (motion.dev's "multi-state badge" pattern) — skipped under reduced motion.
5
+ import { createEffect, on, Switch, Match, type JSX } from 'solid-js'
6
+ import { Loader2, Check, X } from 'lucide-solid'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { motionReduced, animate } from '../lib/motion'
10
+
11
+ /** The lifecycle state a {@link MultiStateBadge} can be in. */
12
+ export type BadgeState = 'idle' | 'loading' | 'success' | 'error'
13
+
14
+ export interface MultiStateBadgeProps {
15
+ /** Current lifecycle state; drives color, icon, and label. */
16
+ state: BadgeState
17
+ /** Override the per-state text. Defaults: idle→'Ready', loading→'Working…', success→'Done', error→'Failed'. */
18
+ labels?: Partial<Record<BadgeState, string>>
19
+ class?: string
20
+ }
21
+
22
+ const DEFAULT_LABELS: Record<BadgeState, string> = {
23
+ idle: 'Ready',
24
+ loading: 'Working…',
25
+ success: 'Done',
26
+ error: 'Failed',
27
+ }
28
+
29
+ const STATE_CLASSES: Record<BadgeState, string> = {
30
+ idle: 'bg-muted text-muted-foreground',
31
+ loading: 'bg-primary/15 text-primary',
32
+ success: 'bg-green-500/15 text-green-600',
33
+ error: 'bg-destructive/15 text-destructive',
34
+ }
35
+
36
+ /**
37
+ * Status badge that animates between `idle` → `loading` → `success` → `error`,
38
+ * morphing its color and swapping its icon/label on each transition. Colors
39
+ * tween via a CSS `transition-colors` class; the pill also gets a spring
40
+ * "pop" and the icon/label cross-fade via Motion's `animate`, both skipped
41
+ * when {@link motionReduced} is true.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <MultiStateBadge state={saveState()} labels={{ error: 'Save failed' }} />
46
+ * ```
47
+ */
48
+ export function MultiStateBadge(props: MultiStateBadgeProps): JSX.Element {
49
+ let rootEl: HTMLSpanElement | undefined
50
+ let contentEl: HTMLSpanElement | undefined
51
+
52
+ const label = () => props.labels?.[props.state] ?? DEFAULT_LABELS[props.state]
53
+
54
+ createEffect(
55
+ on(
56
+ () => props.state,
57
+ () => {
58
+ if (motionReduced()) return
59
+ if (rootEl) animate(rootEl, { scale: [0.9, 1] }, { type: 'spring', stiffness: 500, damping: 20 })
60
+ if (contentEl) animate(contentEl, { opacity: [0, 1] }, { duration: 0.2, ease: 'easeOut' })
61
+ },
62
+ { defer: true },
63
+ ),
64
+ )
65
+
66
+ return (
67
+ <span
68
+ ref={rootEl}
69
+ role="status"
70
+ aria-live="polite"
71
+ class={cn(
72
+ 'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-colors',
73
+ STATE_CLASSES[props.state],
74
+ props.class,
75
+ )}
76
+ >
77
+ <span ref={contentEl} class="inline-flex items-center gap-1.5">
78
+ <Switch>
79
+ <Match when={props.state === 'loading'}>
80
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
81
+ </Match>
82
+ <Match when={props.state === 'success'}>
83
+ <Check class="h-3.5 w-3.5" />
84
+ </Match>
85
+ <Match when={props.state === 'error'}>
86
+ <X class="h-3.5 w-3.5" />
87
+ </Match>
88
+ </Switch>
89
+ <span>{label()}</span>
90
+ </span>
91
+ </span>
92
+ )
93
+ }
@@ -0,0 +1,104 @@
1
+ // A stacked-notifications widget (phone lock-screen style): the newest card is
2
+ // full-size on top and older cards peek out behind it, scaled down and offset.
3
+ // Presentational + controlled — the parent owns `items` (newest first) and
4
+ // removes a card when `onDismiss` fires; the stack re-flows with a CSS transition.
5
+ import { For, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { animateIn, motionReduced } from '../lib/motion'
9
+
10
+ export interface StackNotification {
11
+ id: string | number
12
+ title: string
13
+ description?: string
14
+ icon?: JSX.Element
15
+ }
16
+
17
+ export interface NotificationStackProps {
18
+ /** Newest first. The parent owns this array. */
19
+ items: StackNotification[]
20
+ /** How many cards are visible before the rest collapse behind. @default 3 */
21
+ max?: number
22
+ /** Fired when a card is dismissed; the parent should drop it from `items`. */
23
+ onDismiss?: (id: string | number) => void
24
+ class?: string
25
+ }
26
+
27
+ /**
28
+ * Stacked notifications. Cards behind the front one are offset down and scaled,
29
+ * so they peek from the bottom edge; removing the front card promotes the rest
30
+ * with a smooth transition (skipped under reduced motion). New cards fade/slide
31
+ * in via `animateIn`.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * const [items, setItems] = createStore<StackNotification[]>([])
36
+ * <NotificationStack
37
+ * items={items}
38
+ * onDismiss={(id) => setItems((xs) => xs.filter((x) => x.id !== id))}
39
+ * />
40
+ * ```
41
+ */
42
+ export function NotificationStack(props: NotificationStackProps): JSX.Element {
43
+ const max = () => props.max ?? 3
44
+
45
+ return (
46
+ <div
47
+ role="log"
48
+ aria-live="polite"
49
+ class={cn('relative w-full max-w-sm', props.class)}
50
+ // reserve room for the front card plus the peek of those behind it
51
+ style={{ 'min-height': `${88 + (Math.min(props.items.length, max()) - 1) * 10}px` }}
52
+ >
53
+ <For each={props.items}>
54
+ {(item, i) => {
55
+ const hidden = () => i() > max() - 1
56
+ return (
57
+ <div
58
+ ref={(el) => animateIn(el, { y: -8 })}
59
+ class={cn(
60
+ 'card absolute inset-x-0 top-0 rounded-xl border border-border bg-card p-3 shadow-lg',
61
+ !motionReduced() && 'transition-[transform,opacity] duration-300 ease-out',
62
+ )}
63
+ style={{
64
+ transform: `translateY(${i() * 10}px) scale(${1 - i() * 0.05})`,
65
+ opacity: hidden() ? 0 : 1 - i() * 0.15,
66
+ 'z-index': props.items.length - i(),
67
+ 'pointer-events': hidden() ? 'none' : 'auto',
68
+ 'transform-origin': 'top center',
69
+ }}
70
+ >
71
+ <div class="flex items-start gap-2.5">
72
+ {item.icon && <span class="mt-0.5 shrink-0 text-primary">{item.icon}</span>}
73
+ <div class="min-w-0 flex-1">
74
+ <p class="truncate text-sm font-medium text-foreground">{item.title}</p>
75
+ {item.description && (
76
+ <p class="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
77
+ )}
78
+ </div>
79
+ <button
80
+ type="button"
81
+ aria-label={`Dismiss ${item.title}`}
82
+ onClick={() => props.onDismiss?.(item.id)}
83
+ class="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
84
+ >
85
+ <svg
86
+ viewBox="0 0 24 24"
87
+ class="h-3.5 w-3.5"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ stroke-width="2.5"
91
+ stroke-linecap="round"
92
+ aria-hidden="true"
93
+ >
94
+ <path d="M6 6l12 12M18 6L6 18" />
95
+ </svg>
96
+ </button>
97
+ </div>
98
+ </div>
99
+ )
100
+ }}
101
+ </For>
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,100 @@
1
+ // Compact "now playing" music widget: cover art, title/artist, and an
2
+ // animated equalizer. The bounce is pure CSS (keyframes injected once into
3
+ // the document head), so the bars animate at zero per-frame JS cost — same
4
+ // pattern as LoadingDots. Bars freeze to a static height when playback is
5
+ // paused or under reduced motion.
6
+ import type { JSX } from 'solid-js'
7
+ import { For, Show } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { motionReduced } from '../lib/motion'
11
+
12
+ export interface NowPlayingProps {
13
+ title: string
14
+ artist?: string
15
+ /** Cover art URL. */
16
+ cover?: string
17
+ /** Whether the equalizer bars are animating. @default true */
18
+ playing?: boolean
19
+ class?: string
20
+ }
21
+
22
+ const STYLE_ID = 'a4ui-now-playing-style'
23
+
24
+ function ensureStyleInjected(): void {
25
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return
26
+ const style = document.createElement('style')
27
+ style.id = STYLE_ID
28
+ style.textContent = `
29
+ @keyframes a4-eq {
30
+ 0%, 100% { transform: scaleY(0.3); }
31
+ 50% { transform: scaleY(1); }
32
+ }
33
+ `
34
+ document.head.appendChild(style)
35
+ }
36
+
37
+ // Per-bar duration/delay (seconds) so the bars don't bounce in lockstep.
38
+ const BARS = [
39
+ { duration: 0.6, delay: 0 },
40
+ { duration: 0.5, delay: 0.1 },
41
+ { duration: 0.7, delay: 0.05 },
42
+ { duration: 0.45, delay: 0.15 },
43
+ { duration: 0.65, delay: 0.2 },
44
+ ]
45
+
46
+ /**
47
+ * Compact "now playing" music widget with an animated equalizer. Bars bounce
48
+ * via CSS keyframes while `playing` is true; they freeze to a low static
49
+ * height when paused or under reduced motion.
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * <NowPlaying title="Random Access Memories" artist="Daft Punk" cover="/covers/ram.jpg" playing />
54
+ * ```
55
+ */
56
+ export function NowPlaying(props: NowPlayingProps): JSX.Element {
57
+ ensureStyleInjected()
58
+
59
+ const playing = () => (props.playing ?? true) && !motionReduced()
60
+
61
+ const barStyle = (bar: (typeof BARS)[number], index: number): JSX.CSSProperties => {
62
+ if (!playing()) {
63
+ return {
64
+ height: '30%',
65
+ 'transform-origin': 'bottom',
66
+ }
67
+ }
68
+ return {
69
+ height: '100%',
70
+ 'transform-origin': 'bottom',
71
+ 'animation-name': 'a4-eq',
72
+ 'animation-duration': `${bar.duration}s`,
73
+ 'animation-delay': `${bar.delay + index * 0.02}s`,
74
+ 'animation-iteration-count': 'infinite',
75
+ 'animation-timing-function': 'ease-in-out',
76
+ }
77
+ }
78
+
79
+ return (
80
+ <div
81
+ class={cn('flex items-center gap-3 rounded-xl border border-border bg-card p-3', props.class)}
82
+ aria-label={`Now playing: ${props.title}${props.artist ? ` by ${props.artist}` : ''}`}
83
+ >
84
+ <Show when={props.cover} fallback={<div class="h-12 w-12 shrink-0 rounded-lg bg-muted" />}>
85
+ <img src={props.cover} alt="" class="h-12 w-12 shrink-0 rounded-lg object-cover" />
86
+ </Show>
87
+ <div class="min-w-0 flex-1">
88
+ <p class="truncate font-medium text-foreground">{props.title}</p>
89
+ <Show when={props.artist}>
90
+ <p class="truncate text-sm text-muted-foreground">{props.artist}</p>
91
+ </Show>
92
+ </div>
93
+ <div class="flex h-6 items-end gap-0.5">
94
+ <For each={BARS}>
95
+ {(bar, index) => <span class="w-1 rounded-full bg-primary" style={barStyle(bar, index())} />}
96
+ </For>
97
+ </div>
98
+ </div>
99
+ )
100
+ }
@@ -0,0 +1,51 @@
1
+ // Parallax — moves its children at a fraction of scroll speed for depth,
2
+ // driven by Motion's `scroll` progress callback (0..1) as the element passes
3
+ // through the viewport. No-op (static) under reduced motion.
4
+ import { onCleanup, onMount, type JSX } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+ import { motionReduced, scroll } from '../lib/motion'
8
+
9
+ export interface ParallaxProps {
10
+ children: JSX.Element
11
+ /** Parallax strength; positive moves slower/opposite. Pixels of travel across the scroll range. @default 80 */
12
+ amount?: number
13
+ class?: string
14
+ }
15
+
16
+ /**
17
+ * Wraps `children` in an element that drifts vertically as the page scrolls,
18
+ * creating a depth effect. Tracks scroll progress of the element itself
19
+ * against the viewport via Motion's `scroll`; respects `prefers-reduced-motion`
20
+ * (renders static).
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <Parallax amount={120}>
25
+ * <img src="/hero.png" alt="" />
26
+ * </Parallax>
27
+ * ```
28
+ */
29
+ export function Parallax(props: ParallaxProps): JSX.Element {
30
+ let root!: HTMLDivElement
31
+
32
+ onMount(() => {
33
+ if (motionReduced()) return
34
+
35
+ const amount = props.amount ?? 80
36
+ const stop = scroll(
37
+ (progress: number) => {
38
+ root.style.transform = `translateY(${(progress - 0.5) * -2 * amount}px)`
39
+ },
40
+ { target: root },
41
+ )
42
+
43
+ onCleanup(() => stop())
44
+ })
45
+
46
+ return (
47
+ <div ref={root} class={cn('will-change-transform', props.class)}>
48
+ {props.children}
49
+ </div>
50
+ )
51
+ }
@@ -0,0 +1,95 @@
1
+ // Text that "decodes" into place: characters scramble through random glyphs
2
+ // before locking, left to right, into the final string — motion.dev's
3
+ // scramble-text effect, reimplemented here with a plain setInterval (locking
4
+ // one more character's worth of the string on each tick) rather than the
5
+ // `motion` package, since the effect is character substitution, not a tween.
6
+ import { createSignal, onCleanup, onMount, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+ import { motionReduced } from '../lib/motion'
10
+
11
+ export interface ScrambleTextProps {
12
+ text: string
13
+ /** When to run: on mount, or on hover. @default 'mount' */
14
+ trigger?: 'mount' | 'hover'
15
+ /** Total scramble duration in ms. @default 800 */
16
+ duration?: number
17
+ class?: string
18
+ }
19
+
20
+ const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*<>-_'
21
+ const TICK_MS = 30
22
+
23
+ function randomGlyph(): string {
24
+ return GLYPHS[Math.floor(Math.random() * GLYPHS.length)] as string
25
+ }
26
+
27
+ /**
28
+ * Text that scrambles through random glyphs before "decoding" into the final
29
+ * string, left to right, over `duration` ms. Runs once on mount by default;
30
+ * pass `trigger="hover"` to (re)run it — cleanly restarting if it's still
31
+ * mid-scramble — on pointer hover instead. Renders the final text as-is, with
32
+ * no scramble, when the user prefers reduced motion.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * <ScrambleText text="ACCESS GRANTED" trigger="hover" duration={600} />
37
+ * ```
38
+ */
39
+ export function ScrambleText(props: ScrambleTextProps): JSX.Element {
40
+ // eslint-disable-next-line solid/reactivity -- one-time seed; scramble() re-reads props.text on each run
41
+ const [display, setDisplay] = createSignal(props.text)
42
+ let intervalId: ReturnType<typeof setInterval> | undefined
43
+
44
+ const stop = (): void => {
45
+ if (intervalId === undefined) return
46
+ clearInterval(intervalId)
47
+ intervalId = undefined
48
+ }
49
+
50
+ const scramble = (): void => {
51
+ const text = props.text
52
+ if (motionReduced()) {
53
+ setDisplay(text)
54
+ return
55
+ }
56
+
57
+ // Restart cleanly if a previous scramble (from a prior trigger) is still running.
58
+ stop()
59
+ const totalTicks = Math.max(1, Math.round((props.duration ?? 800) / TICK_MS))
60
+ let tick = 0
61
+
62
+ intervalId = setInterval(() => {
63
+ tick += 1
64
+
65
+ if (tick >= totalTicks) {
66
+ setDisplay(text)
67
+ stop()
68
+ return
69
+ }
70
+
71
+ const lockedCount = Math.floor((tick / totalTicks) * text.length)
72
+ setDisplay(
73
+ text
74
+ .split('')
75
+ .map((char, i) => (char === ' ' || i < lockedCount ? char : randomGlyph()))
76
+ .join(''),
77
+ )
78
+ }, TICK_MS)
79
+ }
80
+
81
+ onMount(() => {
82
+ if (props.trigger !== 'hover') scramble()
83
+ })
84
+ onCleanup(stop)
85
+
86
+ const handleMouseEnter = (): void => {
87
+ if (props.trigger === 'hover') scramble()
88
+ }
89
+
90
+ return (
91
+ <span class={cn('font-mono', props.class)} onMouseEnter={handleMouseEnter}>
92
+ {display()}
93
+ </span>
94
+ )
95
+ }