@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,348 @@
1
+ // Curtain — a controlled, full-screen cover→uncover transition primitive for
2
+ // page/route transitions. It collapses motion.dev's "curtains" example family
3
+ // (fade, sliding doors, blinds, shutter, iris, clip-wipe, pixels) into one component driven by a
4
+ // single `show` boolean: `true` plays the "cover" animation (the curtain
5
+ // closes over the screen), `false` plays "uncover" (the curtain opens,
6
+ // revealing whatever's behind it). Swap your route content while covered —
7
+ // `onCovered` fires the instant the screen is fully masked, `onRevealed`
8
+ // once it's fully open again.
9
+ import { createEffect, createSignal, For, Index, on, onCleanup, onMount, type JSX } from 'solid-js'
10
+
11
+ import { cn } from '../lib/cn'
12
+ import { animate, motionReduced, stagger } from '../lib/motion'
13
+
14
+ export type CurtainVariant = 'fade' | 'doors' | 'blinds' | 'iris' | 'shutter' | 'clip' | 'pixels'
15
+
16
+ export interface CurtainProps {
17
+ /** true = cover the screen; false = uncover (reveal content). */
18
+ show: boolean
19
+ /** Which wipe style. @default 'fade' */
20
+ variant?: CurtainVariant
21
+ /** Overlay color (CSS color / var). @default 'hsl(var(--background))' */
22
+ color?: string
23
+ /** Seconds for one direction. @default 0.6 */
24
+ duration?: number
25
+ /** Called once the screen is fully covered (show went true→covered). */
26
+ onCovered?: () => void
27
+ /** Called once fully uncovered (show went false→revealed). */
28
+ onRevealed?: () => void
29
+ class?: string
30
+ }
31
+
32
+ interface CurtainControls {
33
+ finished: Promise<unknown>
34
+ stop: () => void
35
+ }
36
+
37
+ type CurtainPhase = 'covered' | 'uncovered' | 'covering' | 'uncovering'
38
+
39
+ const STRIP_COUNT = 8
40
+ const STRIP_INDICES = Array.from({ length: STRIP_COUNT }, (_, i) => i)
41
+
42
+ // `pixels` variant: a grid of tiles that pop in/out in a diagonal cascade.
43
+ const PIXEL_COLS = 10
44
+ const PIXEL_ROWS = 6
45
+ const PIXEL_COUNT = PIXEL_COLS * PIXEL_ROWS
46
+ const PIXEL_INDICES = Array.from({ length: PIXEL_COUNT }, (_, i) => i)
47
+
48
+ function combine(controls: CurtainControls[]): CurtainControls {
49
+ return {
50
+ finished: Promise.all(controls.map((c) => c.finished)),
51
+ stop: () => {
52
+ for (const c of controls) c.stop()
53
+ },
54
+ }
55
+ }
56
+
57
+ /**
58
+ * A controlled full-screen curtain for page/route transitions. Set `show` to
59
+ * `true` to cover the viewport, `false` to uncover it, and swap your route
60
+ * content while covered. `variant` picks the wipe style — fade, sliding
61
+ * doors, blinds, shutter (vertical blinds), iris/circle wipe, a left-to-right
62
+ * clip wipe, or a pixel-grid cascade —
63
+ * `onCovered` / `onRevealed` fire once each transition finishes so you can
64
+ * swap content at exactly the right moment. Skips the animation (and still
65
+ * fires the callback on the next microtask) under reduced motion.
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * const [covered, setCovered] = createSignal(false)
70
+ * const goTo = (path: string) => setCovered(true)
71
+ * return (
72
+ * <>
73
+ * <Curtain
74
+ * show={covered()}
75
+ * variant="iris"
76
+ * onCovered={() => { loadRoute(); setCovered(false) }}
77
+ * />
78
+ * <Router />
79
+ * </>
80
+ * )
81
+ * ```
82
+ */
83
+ export function Curtain(props: CurtainProps): JSX.Element {
84
+ // eslint-disable-next-line solid/reactivity -- initial seed only; reactivity handled in the createEffect below
85
+ const [phase, setPhase] = createSignal<CurtainPhase>(props.show ? 'covered' : 'uncovered')
86
+
87
+ let controls: CurtainControls | undefined
88
+ let panelA: HTMLDivElement | undefined
89
+ let panelB: HTMLDivElement | undefined
90
+ const strips: (HTMLDivElement | undefined)[] = []
91
+
92
+ const color = () => props.color ?? 'hsl(var(--background))'
93
+
94
+ function applyFinalState(variant: CurtainVariant, show: boolean): void {
95
+ switch (variant) {
96
+ case 'fade':
97
+ if (panelA) panelA.style.opacity = show ? '1' : '0'
98
+ break
99
+ case 'doors':
100
+ if (panelA) panelA.style.transform = show ? 'translateX(0%)' : 'translateX(-100%)'
101
+ if (panelB) panelB.style.transform = show ? 'translateX(0%)' : 'translateX(100%)'
102
+ break
103
+ case 'blinds':
104
+ for (const el of strips.slice(0, STRIP_COUNT))
105
+ if (el) el.style.transform = show ? 'scaleY(1)' : 'scaleY(0)'
106
+ break
107
+ case 'shutter':
108
+ for (const el of strips.slice(0, STRIP_COUNT))
109
+ if (el) el.style.transform = show ? 'scaleX(1)' : 'scaleX(0)'
110
+ break
111
+ case 'iris':
112
+ if (panelA) panelA.style.clipPath = show ? 'circle(150% at 50% 50%)' : 'circle(0% at 50% 50%)'
113
+ break
114
+ case 'clip':
115
+ if (panelA) panelA.style.clipPath = show ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)'
116
+ break
117
+ case 'pixels':
118
+ for (const el of strips.slice(0, PIXEL_COUNT)) if (el) el.style.opacity = show ? '1' : '0'
119
+ break
120
+ }
121
+ }
122
+
123
+ function play(variant: CurtainVariant, show: boolean, duration: number): CurtainControls | undefined {
124
+ const ease = 'easeInOut' as const
125
+
126
+ switch (variant) {
127
+ case 'fade': {
128
+ if (!panelA) return undefined
129
+ return animate(panelA, { opacity: show ? [0, 1] : [1, 0] }, { duration, ease })
130
+ }
131
+ case 'doors': {
132
+ if (!panelA || !panelB) return undefined
133
+ const left = animate(panelA, { x: show ? ['-100%', '0%'] : ['0%', '-100%'] }, { duration, ease })
134
+ const right = animate(panelB, { x: show ? ['100%', '0%'] : ['0%', '100%'] }, { duration, ease })
135
+ return combine([left, right])
136
+ }
137
+ case 'blinds': {
138
+ const els = strips.slice(0, STRIP_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
139
+ if (els.length === 0) return undefined
140
+ return animate(els, { scaleY: show ? [0, 1] : [1, 0] }, { duration, delay: stagger(0.05), ease })
141
+ }
142
+ case 'shutter': {
143
+ const els = strips.slice(0, STRIP_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
144
+ if (els.length === 0) return undefined
145
+ return animate(els, { scaleX: show ? [0, 1] : [1, 0] }, { duration, delay: stagger(0.05), ease })
146
+ }
147
+ case 'iris': {
148
+ if (!panelA) return undefined
149
+ return animate(
150
+ panelA,
151
+ {
152
+ clipPath: show
153
+ ? ['circle(0% at 50% 50%)', 'circle(150% at 50% 50%)']
154
+ : ['circle(150% at 50% 50%)', 'circle(0% at 50% 50%)'],
155
+ },
156
+ { duration, ease },
157
+ )
158
+ }
159
+ case 'clip': {
160
+ if (!panelA) return undefined
161
+ return animate(
162
+ panelA,
163
+ {
164
+ clipPath: show
165
+ ? ['inset(0 100% 0 0)', 'inset(0 0 0 0)']
166
+ : ['inset(0 0 0 0)', 'inset(0 100% 0 0)'],
167
+ },
168
+ { duration, ease },
169
+ )
170
+ }
171
+ case 'pixels': {
172
+ const els = strips.slice(0, PIXEL_COUNT).filter((el): el is HTMLDivElement => el !== undefined)
173
+ if (els.length === 0) return undefined
174
+ return animate(
175
+ els,
176
+ { opacity: show ? [0, 1] : [1, 0] },
177
+ { duration: duration * 0.5, delay: stagger(0.01, { from: 'first' }), ease },
178
+ )
179
+ }
180
+ default:
181
+ return undefined
182
+ }
183
+ }
184
+
185
+ // Initial paint: the panels' inline styles already sit in the "uncovered"
186
+ // pose, so we only need to snap them to "covered" when show starts true. No
187
+ // animation on mount — that would flash the curtain in from its covered pose.
188
+ onMount(() => {
189
+ if (props.show) applyFinalState(props.variant ?? 'fade', true)
190
+ })
191
+
192
+ // Animate ONLY when `show` actually toggles (defer skips the mount run; variant
193
+ // and duration are read untracked so switching variant while idle never plays).
194
+ createEffect(
195
+ on(
196
+ () => props.show,
197
+ (show) => {
198
+ const variant = props.variant ?? 'fade'
199
+ const duration = props.duration ?? 0.6
200
+
201
+ controls?.stop()
202
+ controls = undefined
203
+ setPhase(show ? 'covering' : 'uncovering')
204
+
205
+ const settle = () => {
206
+ setPhase(show ? 'covered' : 'uncovered')
207
+ if (show) props.onCovered?.()
208
+ else props.onRevealed?.()
209
+ }
210
+
211
+ if (motionReduced()) {
212
+ applyFinalState(variant, show)
213
+ queueMicrotask(settle)
214
+ return
215
+ }
216
+
217
+ const played = play(variant, show, duration)
218
+ if (!played) {
219
+ queueMicrotask(settle)
220
+ return
221
+ }
222
+
223
+ controls = played
224
+ played.finished.then(settle).catch(() => {})
225
+ },
226
+ { defer: true },
227
+ ),
228
+ )
229
+
230
+ onCleanup(() => controls?.stop())
231
+
232
+ return (
233
+ <div
234
+ class={cn('pointer-events-none fixed inset-0 z-[9999]', props.class)}
235
+ aria-hidden="true"
236
+ data-curtain-variant={props.variant ?? 'fade'}
237
+ data-curtain-state={phase()}
238
+ >
239
+ {(props.variant ?? 'fade') === 'fade' && (
240
+ <div
241
+ class="absolute inset-0"
242
+ style={{ position: 'absolute', opacity: 0, background: color() }}
243
+ ref={(el) => {
244
+ panelA = el
245
+ }}
246
+ />
247
+ )}
248
+
249
+ {(props.variant ?? 'fade') === 'doors' && (
250
+ <>
251
+ <div
252
+ class="absolute inset-y-0 left-0 w-1/2"
253
+ style={{ position: 'absolute', transform: 'translateX(-100%)', background: color() }}
254
+ ref={(el) => {
255
+ panelA = el
256
+ }}
257
+ />
258
+ <div
259
+ class="absolute inset-y-0 right-0 w-1/2"
260
+ style={{ position: 'absolute', transform: 'translateX(100%)', background: color() }}
261
+ ref={(el) => {
262
+ panelB = el
263
+ }}
264
+ />
265
+ </>
266
+ )}
267
+
268
+ {(props.variant ?? 'fade') === 'blinds' && (
269
+ <Index each={STRIP_INDICES}>
270
+ {(_, index) => (
271
+ <div
272
+ class="absolute left-0 h-[12.5%] w-full origin-top"
273
+ style={{
274
+ position: 'absolute',
275
+ top: `${index * 12.5}%`,
276
+ transform: 'scaleY(0)',
277
+ background: color(),
278
+ }}
279
+ ref={(el) => {
280
+ strips[index] = el
281
+ }}
282
+ />
283
+ )}
284
+ </Index>
285
+ )}
286
+
287
+ {(props.variant ?? 'fade') === 'shutter' && (
288
+ <For each={STRIP_INDICES}>
289
+ {(_, index) => (
290
+ <div
291
+ class="absolute top-0 h-full w-[12.5%] origin-left"
292
+ style={{
293
+ position: 'absolute',
294
+ left: `${index() * 12.5}%`,
295
+ transform: 'scaleX(0)',
296
+ background: color(),
297
+ }}
298
+ ref={(el) => {
299
+ strips[index()] = el
300
+ }}
301
+ />
302
+ )}
303
+ </For>
304
+ )}
305
+
306
+ {(props.variant ?? 'fade') === 'iris' && (
307
+ <div
308
+ class="absolute inset-0"
309
+ style={{ position: 'absolute', 'clip-path': 'circle(0% at 50% 50%)', background: color() }}
310
+ ref={(el) => {
311
+ panelA = el
312
+ }}
313
+ />
314
+ )}
315
+
316
+ {(props.variant ?? 'fade') === 'clip' && (
317
+ <div
318
+ class="absolute inset-0"
319
+ style={{ position: 'absolute', 'clip-path': 'inset(0 100% 0 0)', background: color() }}
320
+ ref={(el) => {
321
+ panelA = el
322
+ }}
323
+ />
324
+ )}
325
+
326
+ {(props.variant ?? 'fade') === 'pixels' && (
327
+ <div
328
+ class="absolute inset-0 grid"
329
+ style={{
330
+ 'grid-template-columns': `repeat(${PIXEL_COLS}, 1fr)`,
331
+ 'grid-template-rows': `repeat(${PIXEL_ROWS}, 1fr)`,
332
+ }}
333
+ >
334
+ <Index each={PIXEL_INDICES}>
335
+ {(_, index) => (
336
+ <div
337
+ style={{ opacity: 0, background: color() }}
338
+ ref={(el) => {
339
+ strips[index] = el
340
+ }}
341
+ />
342
+ )}
343
+ </Index>
344
+ </div>
345
+ )}
346
+ </div>
347
+ )
348
+ }
@@ -0,0 +1,229 @@
1
+ // Shared-element transition: a compact `trigger` (a card) morphs into an
2
+ // expanded overlay panel and back, using a FLIP animation. We animate the
3
+ // panel's position and SIZE (top/left/width/height) rather than a transform
4
+ // scale, so its content never stretches. Covers the motion.dev "family dialog"
5
+ // (size="dialog") and "app store layout" (size="full") patterns with one API.
6
+ import { createSignal, Show, onCleanup, onMount, type JSX } from 'solid-js'
7
+ import { Portal } from 'solid-js/web'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced } from '../lib/motion'
11
+
12
+ type Rect = { top: number; left: number; width: number; height: number }
13
+
14
+ export interface ExpandableProps {
15
+ /** Collapsed content — the card users click to expand. */
16
+ trigger: JSX.Element
17
+ /** Expanded panel content. */
18
+ children: JSX.Element
19
+ /** `dialog` = centered panel capped at `maxWidth`; `full` = near-fullscreen. @default 'dialog' */
20
+ size?: 'dialog' | 'full'
21
+ /** Max width (px) of the expanded panel when `size='dialog'`. @default 640 */
22
+ maxWidth?: number
23
+ /** Notified when the panel opens/closes. */
24
+ onOpenChange?: (open: boolean) => void
25
+ /** Class for the inline trigger wrapper. */
26
+ class?: string
27
+ /** Class for the expanded panel surface. */
28
+ panelClass?: string
29
+ }
30
+
31
+ const PAD = 24
32
+
33
+ /**
34
+ * Expands a card into an overlay panel with a shared-element (FLIP) transition —
35
+ * the card appears to grow into the dialog and shrink back on close. Animates
36
+ * position + size (never scale) so content stays crisp. Falls back to a plain
37
+ * show/hide under reduced motion. Close with the ✕, a backdrop click, or Escape.
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <Expandable
42
+ * trigger={<Card class="cursor-pointer">Tap to expand</Card>}
43
+ * size="dialog"
44
+ * >
45
+ * <div class="p-6">Full details here…</div>
46
+ * </Expandable>
47
+ * ```
48
+ */
49
+ export function Expandable(props: ExpandableProps): JSX.Element {
50
+ const [open, setOpen] = createSignal(false)
51
+ let triggerEl: HTMLDivElement | undefined
52
+ let panelEl: HTMLDivElement | undefined
53
+ let backdropEl: HTMLDivElement | undefined
54
+ let originRect: Rect | undefined
55
+ let controls: ReturnType<typeof animate> | undefined
56
+
57
+ const rectOf = (el: Element): Rect => {
58
+ const r = el.getBoundingClientRect()
59
+ return { top: r.top, left: r.left, width: r.width, height: r.height }
60
+ }
61
+
62
+ // The panel's resting position/size, computed (not scaled) so text stays sharp.
63
+ const finalRect = (el: HTMLDivElement): Rect => {
64
+ const vw = window.innerWidth
65
+ const vh = window.innerHeight
66
+ if (props.size === 'full') {
67
+ const width = vw - PAD * 2
68
+ const height = vh - PAD * 2
69
+ return { left: PAD, top: PAD, width, height }
70
+ }
71
+ const width = Math.min(props.maxWidth ?? 640, vw - PAD * 2)
72
+ // Measure the natural height at the target width.
73
+ el.style.width = `${width}px`
74
+ el.style.height = 'auto'
75
+ el.style.maxHeight = `${vh - PAD * 2}px`
76
+ const natH = el.getBoundingClientRect().height
77
+ const height = Math.min(natH, vh - PAD * 2)
78
+ return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
79
+ }
80
+
81
+ const place = (el: HTMLDivElement, r: Rect) => {
82
+ el.style.top = `${r.top}px`
83
+ el.style.left = `${r.left}px`
84
+ el.style.width = `${r.width}px`
85
+ el.style.height = `${r.height}px`
86
+ }
87
+
88
+ const doOpen = () => {
89
+ if (open() || !triggerEl) return
90
+ originRect = rectOf(triggerEl)
91
+ setOpen(true)
92
+ props.onOpenChange?.(true)
93
+ }
94
+
95
+ // Runs once the portalled panel is in the DOM: FLIP from the card to the panel.
96
+ const runOpenFlip = () => {
97
+ const el = panelEl
98
+ if (!el) return
99
+ const final = finalRect(el)
100
+ if (motionReduced() || !originRect) {
101
+ place(el, final)
102
+ el.style.overflow = 'auto'
103
+ el.style.opacity = '1'
104
+ return
105
+ }
106
+ place(el, originRect) // first
107
+ el.style.opacity = '1'
108
+ void el.offsetWidth // force reflow
109
+ controls?.stop()
110
+ controls = animate(
111
+ el,
112
+ {
113
+ top: [originRect.top, final.top],
114
+ left: [originRect.left, final.left],
115
+ width: [originRect.width, final.width],
116
+ height: [originRect.height, final.height],
117
+ },
118
+ { type: 'spring', stiffness: 280, damping: 32 },
119
+ )
120
+ if (backdropEl) animate(backdropEl, { opacity: [0, 1] }, { duration: 0.25, ease: 'easeOut' })
121
+ controls.finished
122
+ .then(() => {
123
+ el.style.overflow = 'auto'
124
+ })
125
+ .catch(() => {})
126
+ }
127
+
128
+ const doClose = () => {
129
+ if (!open()) return
130
+ const el = panelEl
131
+ const back = triggerEl ? rectOf(triggerEl) : originRect
132
+ if (!el || !back || motionReduced()) {
133
+ finishClose()
134
+ return
135
+ }
136
+ controls?.stop()
137
+ el.style.overflow = 'hidden'
138
+ place(el, rectOf(el)) // pin current
139
+ void el.offsetWidth
140
+ controls = animate(
141
+ el,
142
+ { top: back.top, left: back.left, width: back.width, height: back.height, opacity: [1, 0.35] },
143
+ { duration: 0.3, ease: 'easeInOut' },
144
+ )
145
+ if (backdropEl) animate(backdropEl, { opacity: [1, 0] }, { duration: 0.25, ease: 'easeIn' })
146
+ controls.finished.then(finishClose).catch(finishClose)
147
+ }
148
+
149
+ const finishClose = () => {
150
+ setOpen(false)
151
+ props.onOpenChange?.(false)
152
+ }
153
+
154
+ const onKeyDown = (e: KeyboardEvent) => {
155
+ if (e.key === 'Escape') doClose()
156
+ }
157
+
158
+ onMount(() => document.addEventListener('keydown', onKeyDown))
159
+ onCleanup(() => {
160
+ document.removeEventListener('keydown', onKeyDown)
161
+ controls?.stop()
162
+ })
163
+
164
+ return (
165
+ <>
166
+ <div
167
+ ref={triggerEl}
168
+ role="button"
169
+ tabindex={0}
170
+ class={cn('cursor-pointer', props.class)}
171
+ onClick={doOpen}
172
+ onKeyDown={(e) => {
173
+ if (e.key === 'Enter' || e.key === ' ') {
174
+ e.preventDefault()
175
+ doOpen()
176
+ }
177
+ }}
178
+ >
179
+ {props.trigger}
180
+ </div>
181
+
182
+ <Show when={open()}>
183
+ <Portal>
184
+ <div
185
+ ref={backdropEl}
186
+ aria-hidden="true"
187
+ onClick={doClose}
188
+ class="fixed inset-0 z-[9998] bg-black/50 backdrop-blur-sm"
189
+ style={{ opacity: motionReduced() ? '1' : '0' }}
190
+ />
191
+ <div
192
+ ref={(el) => {
193
+ panelEl = el
194
+ // Measure/FLIP after layout settles.
195
+ requestAnimationFrame(runOpenFlip)
196
+ }}
197
+ role="dialog"
198
+ aria-modal="true"
199
+ class={cn(
200
+ 'card fixed z-[9999] overflow-hidden rounded-2xl border border-border bg-card shadow-2xl',
201
+ props.panelClass,
202
+ )}
203
+ style={{ opacity: '0', margin: '0' }}
204
+ >
205
+ <button
206
+ type="button"
207
+ aria-label="Close"
208
+ onClick={doClose}
209
+ class="absolute right-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full bg-muted/80 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
210
+ >
211
+ <svg
212
+ viewBox="0 0 24 24"
213
+ class="h-4 w-4"
214
+ fill="none"
215
+ stroke="currentColor"
216
+ stroke-width="2.5"
217
+ stroke-linecap="round"
218
+ aria-hidden="true"
219
+ >
220
+ <path d="M6 6l12 12M18 6L6 18" />
221
+ </svg>
222
+ </button>
223
+ {props.children}
224
+ </div>
225
+ </Portal>
226
+ </Show>
227
+ </>
228
+ )
229
+ }
@@ -0,0 +1,63 @@
1
+ // FillText — text with a color "fill" band sweeping left-to-right on loop,
2
+ // for loading states. The sweep is a background-position animation clipped
3
+ // to the glyphs via `background-clip: text`, driven by Motion's `animate`.
4
+ // Falls back to plain static text under reduced motion.
5
+ import { onCleanup, onMount, Show, type JSX } from 'solid-js'
6
+
7
+ import { cn } from '../lib/cn'
8
+ import { animate, motionReduced } from '../lib/motion'
9
+
10
+ export interface FillTextProps {
11
+ text: string
12
+ /** Seconds per sweep. @default 1.6 */
13
+ duration?: number
14
+ class?: string
15
+ }
16
+
17
+ /**
18
+ * Renders `text` with a bright gradient band that sweeps across the glyphs
19
+ * repeatedly, useful as a lightweight loading indicator. Respects
20
+ * `prefers-reduced-motion` by rendering plain muted text instead of animating.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * <FillText text="Loading results…" />
25
+ * ```
26
+ */
27
+ export function FillText(props: FillTextProps): JSX.Element {
28
+ let el!: HTMLSpanElement
29
+ let controls: { stop: () => void } | undefined
30
+
31
+ onMount(() => {
32
+ if (motionReduced()) return
33
+
34
+ controls = animate(
35
+ el,
36
+ { backgroundPosition: ['200% 0%', '-200% 0%'] },
37
+ { duration: props.duration ?? 1.6, ease: 'linear', repeat: Infinity },
38
+ )
39
+
40
+ onCleanup(() => controls?.stop())
41
+ })
42
+
43
+ return (
44
+ <span role="status" class={cn('font-medium', props.class)}>
45
+ <Show when={!motionReduced()} fallback={<span class="text-muted-foreground">{props.text}</span>}>
46
+ <span
47
+ ref={el}
48
+ style={{
49
+ background:
50
+ 'linear-gradient(90deg, hsl(var(--muted-foreground)) 0%, hsl(var(--muted-foreground)) 40%, hsl(var(--foreground)) 50%, hsl(var(--muted-foreground)) 60%, hsl(var(--muted-foreground)) 100%)',
51
+ 'background-size': '200% 100%',
52
+ '-webkit-background-clip': 'text',
53
+ 'background-clip': 'text',
54
+ '-webkit-text-fill-color': 'transparent',
55
+ color: 'transparent',
56
+ }}
57
+ >
58
+ {props.text}
59
+ </span>
60
+ </Show>
61
+ </span>
62
+ )
63
+ }