@smicolon/fasla-ui 0.2.4 → 0.3.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.
Files changed (57) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +91 -23
  4. package/dist/registry/index.js +31 -2
  5. package/dist/tokens/index.d.ts +75 -6
  6. package/dist/tokens/index.js +59 -20
  7. package/package.json +4 -1
  8. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  9. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  10. package/registry/blocks/data-table/DataTable.tsx +53 -16
  11. package/registry/blocks/data-table/index.ts +1 -0
  12. package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
  13. package/registry/blocks/empty-state/EmptyState.tsx +22 -31
  14. package/registry/blocks/form-section/FormSection.tsx +1 -1
  15. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  16. package/registry/blocks/navbar/Navbar.tsx +8 -23
  17. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  18. package/registry/blocks/sidebar/Sidebar.tsx +9 -16
  19. package/registry/blocks/stats-card/StatsCard.tsx +9 -27
  20. package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
  21. package/registry/effects/border-beam/border-beam.tsx +77 -53
  22. package/registry/effects/effects.test.tsx +266 -0
  23. package/registry/effects/glow-card/glow-card.tsx +68 -43
  24. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  25. package/registry/effects/spotlight/spotlight.tsx +73 -29
  26. package/registry/effects/text-reveal/index.ts +1 -0
  27. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  28. package/registry/effects/text-reveal/text-reveal.tsx +68 -21
  29. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  30. package/registry/text-props-undefined.test.tsx +73 -0
  31. package/registry/ui/avatar/avatar.test.tsx +316 -0
  32. package/registry/ui/avatar/avatar.tsx +235 -108
  33. package/registry/ui/avatar/index.ts +1 -1
  34. package/registry/ui/badge/badge.test.tsx +267 -32
  35. package/registry/ui/badge/badge.tsx +233 -21
  36. package/registry/ui/button/button.test.tsx +6 -0
  37. package/registry/ui/button/button.tsx +6 -23
  38. package/registry/ui/card/card.tsx +1 -1
  39. package/registry/ui/checkbox/checkbox.tsx +13 -26
  40. package/registry/ui/combobox/combobox.test.tsx +43 -0
  41. package/registry/ui/combobox/combobox.tsx +27 -55
  42. package/registry/ui/input/input.test.tsx +2 -2
  43. package/registry/ui/input/input.tsx +4 -4
  44. package/registry/ui/radio/index.ts +1 -0
  45. package/registry/ui/radio/radio.test.tsx +248 -0
  46. package/registry/ui/radio/radio.tsx +208 -0
  47. package/registry/ui/select/select.tsx +4 -14
  48. package/registry/ui/skeleton/skeleton.tsx +1 -1
  49. package/registry/ui/status-indicator/index.ts +6 -0
  50. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  51. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  52. package/registry/ui/switch/index.ts +1 -1
  53. package/registry/ui/switch/switch.test.tsx +323 -0
  54. package/registry/ui/switch/switch.tsx +254 -57
  55. package/registry/ui/textarea/textarea.tsx +1 -1
  56. package/registry.json +78 -15
  57. package/tailwind-preset.ts +143 -0
@@ -8,7 +8,7 @@ export interface ShimmerButtonProps
8
8
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9
9
  /** Shimmer color */
10
10
  shimmerColor?: string
11
- /** Shimmer size in pixels */
11
+ /** Width of the sheen, as any CSS length or percentage of the button */
12
12
  shimmerSize?: string
13
13
  /** Border radius */
14
14
  borderRadius?: string
@@ -18,6 +18,26 @@ export interface ShimmerButtonProps
18
18
  background?: string
19
19
  }
20
20
 
21
+ /** The value, or the fallback when it is missing or blank (a cleared control, say). */
22
+ const filled = (value: string | undefined, fallback: string) =>
23
+ value && value.trim() ? value : fallback
24
+
25
+ const DEFAULTS = {
26
+ // primary-foreground, not primary: a primary sheen on the primary background is invisible
27
+ shimmerColor: "color-mix(in oklch, var(--primary-foreground) 35%, transparent)",
28
+ shimmerSize: "100%",
29
+ shimmerDuration: "2s",
30
+ borderRadius: "0.5rem",
31
+ background: "var(--primary)",
32
+ }
33
+
34
+ const SHIMMER_KEYFRAMES = `
35
+ @keyframes fasla-shimmer {
36
+ from { transform: translateX(-100%); }
37
+ to { transform: translateX(100%); }
38
+ }
39
+ `
40
+
21
41
  /**
22
42
  * A button with an animated shimmer effect.
23
43
  * The shimmer respects prefers-reduced-motion.
@@ -28,11 +48,11 @@ export const ShimmerButton = React.forwardRef<
28
48
  >(
29
49
  (
30
50
  {
31
- shimmerColor = "hsl(var(--primary) / 0.2)",
32
- shimmerSize = "0.1em",
33
- shimmerDuration = "2s",
34
- borderRadius = "0.5rem",
35
- background = "hsl(var(--primary))",
51
+ shimmerColor,
52
+ shimmerSize,
53
+ shimmerDuration,
54
+ borderRadius,
55
+ background,
36
56
  className,
37
57
  children,
38
58
  ...props
@@ -45,40 +65,59 @@ export const ShimmerButton = React.forwardRef<
45
65
  <button
46
66
  ref={ref}
47
67
  className={cn(
48
- "group relative inline-flex h-10 items-center justify-center overflow-hidden whitespace-nowrap px-6 py-2 text-sm font-medium text-primary-foreground transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
68
+ "group relative inline-flex h-10 items-center justify-center overflow-hidden whitespace-nowrap px-6 py-2 text-sm font-medium text-primary-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
69
+ // Plain hover, gated in JS rather than with a stacked motion-safe
70
+ // hover variant: Storybook's pseudo-states addon rewrites that
71
+ // stacked rule into invalid CSS and throws. Tailwind reads comments
72
+ // too, so the stacked class name must not appear here either.
73
+ !prefersReducedMotion && "hover:scale-105",
49
74
  className
50
75
  )}
51
76
  style={
52
77
  {
53
- "--shimmer-color": shimmerColor,
54
- "--shimmer-size": shimmerSize,
55
- "--shimmer-duration": shimmerDuration,
56
- "--border-radius": borderRadius,
57
- "--background": background,
78
+ // A blank value (a cleared control, say) falls back to the default
79
+ // rather than emptying the variable, which would hide the sheen,
80
+ // stop it, square the corners or clear the background.
81
+ "--shimmer-color": filled(shimmerColor, DEFAULTS.shimmerColor),
82
+ "--shimmer-size": filled(shimmerSize, DEFAULTS.shimmerSize),
83
+ "--shimmer-duration": filled(shimmerDuration, DEFAULTS.shimmerDuration),
84
+ "--border-radius": filled(borderRadius, DEFAULTS.borderRadius),
85
+ "--background": filled(background, DEFAULTS.background),
58
86
  borderRadius: "var(--border-radius)",
59
87
  background: "var(--background)",
60
88
  } as React.CSSProperties
61
89
  }
62
90
  {...props}
63
91
  >
64
- {/* Shimmer effect */}
92
+ {/* Shimmer effect: mirrored in RTL so it sweeps in the reading direction */}
65
93
  {!prefersReducedMotion && (
66
94
  <div
67
- className="absolute inset-0 overflow-hidden"
95
+ aria-hidden="true"
96
+ className="pointer-events-none absolute inset-0 overflow-hidden rtl:-scale-x-100"
68
97
  style={{ borderRadius: "var(--border-radius)" }}
69
98
  >
99
+ {/*
100
+ The keyframes ship with the component, so it animates without any
101
+ Tailwind config. The track is the button's width and travels from
102
+ fully before it to fully after it; the sheen sits at its centre.
103
+ */}
104
+ <style dangerouslySetInnerHTML={{ __html: SHIMMER_KEYFRAMES }} />
70
105
  <div
71
- className="animate-shimmer absolute inset-0 -translate-x-full"
106
+ className="absolute inset-0 flex justify-center"
72
107
  style={{
73
- background: `linear-gradient(
74
- 90deg,
75
- transparent 0%,
76
- var(--shimmer-color) 50%,
77
- transparent 100%
78
- )`,
79
- animationDuration: "var(--shimmer-duration)",
108
+ transform: "translateX(-100%)",
109
+ animation: "fasla-shimmer var(--shimmer-duration) ease-in-out infinite",
80
110
  }}
81
- />
111
+ >
112
+ <div
113
+ className="h-full shrink-0"
114
+ style={{
115
+ width: "var(--shimmer-size)",
116
+ background:
117
+ "linear-gradient(90deg, transparent 0%, var(--shimmer-color) 50%, transparent 100%)",
118
+ }}
119
+ />
120
+ </div>
82
121
  </div>
83
122
  )}
84
123
 
@@ -1,9 +1,16 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { motion, useMotionValue, useSpring, useReducedMotion } from "framer-motion"
4
+ import { AnimatePresence, motion, useMotionValue, useSpring, useReducedMotion } from "framer-motion"
5
5
  import { cn } from "../../../src/lib/utils"
6
6
 
7
+ /** A layout effect in the browser, a plain effect on the server (where neither runs). */
8
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect
9
+
10
+ /** The value, or the fallback when it is missing or blank (a cleared control, say). */
11
+ const filled = (value: string | undefined, fallback: string) =>
12
+ value && value.trim() ? value : fallback
13
+
7
14
  export interface SpotlightProps extends React.HTMLAttributes<HTMLDivElement> {
8
15
  /** Spotlight color */
9
16
  color?: string
@@ -20,7 +27,7 @@ export interface SpotlightProps extends React.HTMLAttributes<HTMLDivElement> {
20
27
  * Respects prefers-reduced-motion by disabling the effect.
21
28
  */
22
29
  export function Spotlight({
23
- color = "hsl(var(--primary) / 0.15)",
30
+ color: colorProp,
24
31
  size = 400,
25
32
  blur = 80,
26
33
  opacity = 1,
@@ -28,6 +35,7 @@ export function Spotlight({
28
35
  children,
29
36
  ...props
30
37
  }: SpotlightProps) {
38
+ const color = filled(colorProp, "color-mix(in oklch, var(--primary) 15%, transparent)")
31
39
  const prefersReducedMotion = useReducedMotion()
32
40
  const containerRef = React.useRef<HTMLDivElement>(null)
33
41
 
@@ -38,18 +46,43 @@ export function Spotlight({
38
46
  const springX = useSpring(mouseX, springConfig)
39
47
  const springY = useSpring(mouseY, springConfig)
40
48
 
49
+ // The motion values hold the light's centre, and the light is offset by half
50
+ // its size through left and top, so a change of size keeps it centred on the
51
+ // same point instead of drifting away from the pointer.
52
+ //
53
+ // Rest at the container's centre, not its top-left corner, until the pointer
54
+ // moves, and follow that centre while the container resizes (a sidebar
55
+ // opening, say).
56
+ const pointerMoved = React.useRef(false)
57
+ useIsomorphicLayoutEffect(() => {
58
+ const el = containerRef.current
59
+ if (!el) return
60
+ const centre = () => {
61
+ if (pointerMoved.current) return
62
+ const x = el.offsetWidth / 2
63
+ const y = el.offsetHeight / 2
64
+ mouseX.jump(x)
65
+ mouseY.jump(y)
66
+ springX.jump(x)
67
+ springY.jump(y)
68
+ }
69
+ centre()
70
+ if (typeof ResizeObserver === "undefined") return
71
+ const observer = new ResizeObserver(centre)
72
+ observer.observe(el)
73
+ return () => observer.disconnect()
74
+ }, [mouseX, mouseY, springX, springY, prefersReducedMotion])
75
+
41
76
  const handleMouseMove = React.useCallback(
42
77
  (e: React.MouseEvent<HTMLDivElement>) => {
43
78
  if (!containerRef.current || prefersReducedMotion) return
44
79
 
80
+ pointerMoved.current = true
45
81
  const rect = containerRef.current.getBoundingClientRect()
46
- const x = e.clientX - rect.left - size / 2
47
- const y = e.clientY - rect.top - size / 2
48
-
49
- mouseX.set(x)
50
- mouseY.set(y)
82
+ mouseX.set(e.clientX - rect.left)
83
+ mouseY.set(e.clientY - rect.top)
51
84
  },
52
- [mouseX, mouseY, size, prefersReducedMotion]
85
+ [mouseX, mouseY, prefersReducedMotion]
53
86
  )
54
87
 
55
88
  if (prefersReducedMotion) {
@@ -64,15 +97,19 @@ export function Spotlight({
64
97
  <div
65
98
  ref={containerRef}
66
99
  onMouseMove={handleMouseMove}
67
- className={cn("relative overflow-hidden", className)}
100
+ className={cn("relative isolate overflow-hidden", className)}
68
101
  {...props}
69
102
  >
70
- {/* Spotlight */}
103
+ {/* Spotlight. Placed from the top-left corner (a flex parent that centres
104
+ its content would otherwise move the origin), then pulled back by half
105
+ its size so x and y are its centre. */}
71
106
  <motion.div
72
107
  className="pointer-events-none absolute -z-10 rounded-full"
73
108
  style={{
74
109
  x: springX,
75
110
  y: springY,
111
+ left: -size / 2,
112
+ top: -size / 2,
76
113
  width: size,
77
114
  height: size,
78
115
  background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
@@ -98,12 +135,13 @@ export interface SpotlightCardProps extends React.HTMLAttributes<HTMLDivElement>
98
135
  * Card with spotlight effect on hover.
99
136
  */
100
137
  export function SpotlightCard({
101
- spotlightColor = "hsl(var(--primary) / 0.1)",
138
+ spotlightColor: spotlightColorProp,
102
139
  spotlightSize = 300,
103
140
  className,
104
141
  children,
105
142
  ...props
106
143
  }: SpotlightCardProps) {
144
+ const spotlightColor = filled(spotlightColorProp, "color-mix(in oklch, var(--primary) 10%, transparent)")
107
145
  const prefersReducedMotion = useReducedMotion()
108
146
  const containerRef = React.useRef<HTMLDivElement>(null)
109
147
  const [isHovered, setIsHovered] = React.useState(false)
@@ -132,28 +170,34 @@ export function SpotlightCard({
132
170
  onMouseEnter={() => setIsHovered(true)}
133
171
  onMouseLeave={() => setIsHovered(false)}
134
172
  className={cn(
135
- "relative overflow-hidden rounded-xl border bg-card p-6 shadow",
173
+ "relative isolate overflow-hidden rounded-xl border bg-card p-6 shadow",
136
174
  className
137
175
  )}
138
176
  {...props}
139
177
  >
140
- {/* Spotlight */}
141
- {!prefersReducedMotion && isHovered && (
142
- <motion.div
143
- className="pointer-events-none absolute -z-10 rounded-full"
144
- style={{
145
- x: mouseX,
146
- y: mouseY,
147
- width: spotlightSize,
148
- height: spotlightSize,
149
- background: `radial-gradient(circle, ${spotlightColor} 0%, transparent 70%)`,
150
- transform: "translate(-50%, -50%)",
151
- }}
152
- initial={{ opacity: 0 }}
153
- animate={{ opacity: 1 }}
154
- exit={{ opacity: 0 }}
155
- />
156
- )}
178
+ {/*
179
+ Spotlight. Centred on the pointer by offsetting half its size: a CSS
180
+ translate(-50%, -50%) is overwritten by the x and y motion values.
181
+ */}
182
+ <AnimatePresence>
183
+ {!prefersReducedMotion && isHovered && (
184
+ <motion.div
185
+ className="pointer-events-none absolute -z-10 rounded-full"
186
+ style={{
187
+ x: mouseX,
188
+ y: mouseY,
189
+ left: -spotlightSize / 2,
190
+ top: -spotlightSize / 2,
191
+ width: spotlightSize,
192
+ height: spotlightSize,
193
+ background: `radial-gradient(circle, ${spotlightColor} 0%, transparent 70%)`,
194
+ }}
195
+ initial={{ opacity: 0 }}
196
+ animate={{ opacity: 1 }}
197
+ exit={{ opacity: 0 }}
198
+ />
199
+ )}
200
+ </AnimatePresence>
157
201
 
158
202
  {/* Content */}
159
203
  {children}
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  TextReveal,
3
3
  WordReveal,
4
+ revealUnits,
4
5
  type TextRevealProps,
5
6
  type WordRevealProps,
6
7
  } from "./text-reveal"
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, beforeAll } from "vitest"
2
+ import { render } from "@testing-library/react"
3
+ import { TextReveal, revealUnits } from "./text-reveal"
4
+
5
+ // framer-motion's useInView needs an observer, which jsdom does not provide.
6
+ beforeAll(() => {
7
+ globalThis.IntersectionObserver ??= class {
8
+ observe() {}
9
+ unobserve() {}
10
+ disconnect() {}
11
+ } as unknown as typeof IntersectionObserver
12
+ })
13
+
14
+ describe("TextReveal units", () => {
15
+ it("splits Latin text into characters", () => {
16
+ expect(revealUnits("Hi there")).toEqual(["H", "i", " ", "t", "h", "e", "r", "e"])
17
+ })
18
+
19
+ it("keeps a combining mark with its letter", () => {
20
+ expect(revealUnits("été")).toEqual(["é", "t", "é"])
21
+ })
22
+
23
+ it("splits Arabic into words, so the letters stay joined", () => {
24
+ expect(revealUnits("مكوّن جاهز")).toEqual(["مكوّن", " ", "جاهز"])
25
+ })
26
+
27
+ it("renders one span per Arabic word", () => {
28
+ const { container } = render(<TextReveal text="أطلق متجرك اليوم" triggerOnView={false} />)
29
+ const spans = Array.from(container.querySelectorAll("span[aria-hidden]"), (span) => span.textContent)
30
+ expect(spans).toEqual(["أطلق", "متجرك", "اليوم"])
31
+ })
32
+
33
+ it("gives assistive tech the whole text once", () => {
34
+ const { container } = render(<TextReveal text="Hello" triggerOnView={false} />)
35
+ expect(container.querySelector(".sr-only")?.textContent).toBe("Hello")
36
+ expect(container.querySelectorAll("span[aria-hidden]")).toHaveLength(5)
37
+ })
38
+
39
+ it("keeps whitespace as plain text, so white-space rules apply as they do to the unanimated text", () => {
40
+ const text = "أطلق متجرك\nاليوم"
41
+ const { container } = render(<TextReveal text={text} triggerOnView={false} className="whitespace-pre-wrap" />)
42
+ const root = container.firstElementChild!
43
+ const visible = Array.from(root.childNodes)
44
+ .filter((node) => !(node instanceof HTMLElement && node.classList.contains("sr-only")))
45
+ .map((node) => node.textContent)
46
+ .join("")
47
+ expect(visible).toBe(text)
48
+ expect(root.querySelectorAll("span[aria-hidden]")).toHaveLength(3)
49
+ })
50
+
51
+ it("keeps a combining mark with its letter when Intl.Segmenter is missing", () => {
52
+ const intl = Intl as unknown as { Segmenter?: unknown }
53
+ const saved = intl.Segmenter
54
+ delete intl.Segmenter
55
+ try {
56
+ expect(revealUnits("e\u0301te\u0300")).toEqual(["e\u0301", "t", "e\u0300"])
57
+ } finally {
58
+ intl.Segmenter = saved
59
+ }
60
+ })
61
+
62
+ it("is exported from the effect's entry point", async () => {
63
+ const entry = await import("./index")
64
+ expect(entry.revealUnits).toBe(revealUnits)
65
+ })
66
+ })
@@ -16,7 +16,42 @@ export interface TextRevealProps extends React.HTMLAttributes<HTMLDivElement> {
16
16
  }
17
17
 
18
18
  /**
19
- * Text reveal animation that reveals text character by character.
19
+ * Scripts whose letters join to their neighbours: Arabic (with its supplements
20
+ * and presentation forms), Syriac, N'Ko and Mandaic. A span per letter would
21
+ * break the joins, so these reveal by word instead. The range also takes in
22
+ * Thaana, which does not join; revealing it by word is harmless.
23
+ */
24
+ const JOINED_SCRIPT = /[\u0600-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/
25
+
26
+ /** The slice of Intl.Segmenter used here, typed locally: older TypeScript libs lack it. */
27
+ type GraphemeSegmenter = new (
28
+ locale: undefined,
29
+ options: { granularity: "grapheme" }
30
+ ) => { segment(text: string): Iterable<{ segment: string }> }
31
+
32
+ /** Splits into user-perceived characters, so a mark stays with its letter. */
33
+ function splitGraphemes(text: string): string[] {
34
+ const Segmenter = (Intl as unknown as { Segmenter?: GraphemeSegmenter }).Segmenter
35
+ if (Segmenter) {
36
+ const segmenter = new Segmenter(undefined, { granularity: "grapheme" })
37
+ return Array.from(segmenter.segment(text), (part) => part.segment)
38
+ }
39
+ // Without Segmenter (Firefox before 125, for one), keep each combining mark
40
+ // with the character before it, so a letter and its accent animate together.
41
+ return text.match(/\P{M}\p{M}*|\p{M}+/gu) ?? []
42
+ }
43
+
44
+ /**
45
+ * Splits the text into the pieces TextReveal animates: characters, or words
46
+ * with their spaces for a joined script such as Arabic.
47
+ */
48
+ export function revealUnits(text: string): string[] {
49
+ return JOINED_SCRIPT.test(text) ? text.split(/(\s+)/).filter(Boolean) : splitGraphemes(text)
50
+ }
51
+
52
+ /**
53
+ * Text reveal animation that reveals text character by character. Arabic and
54
+ * other joined scripts reveal word by word, so their letters stay connected.
20
55
  * Respects prefers-reduced-motion by showing text immediately.
21
56
  */
22
57
  export function TextReveal({
@@ -32,7 +67,7 @@ export function TextReveal({
32
67
  const isInView = useInView(ref, { once: true, amount: 0.5 })
33
68
 
34
69
  const shouldAnimate = triggerOnView ? isInView : true
35
- const characters = text.split("")
70
+ const units = revealUnits(text)
36
71
 
37
72
  if (prefersReducedMotion) {
38
73
  return (
@@ -48,25 +83,35 @@ export function TextReveal({
48
83
  className={cn("inline-block", className)}
49
84
  {...props}
50
85
  >
51
- {characters.map((char, i) => (
52
- <motion.span
53
- key={`${char}-${i}`}
54
- className="inline-block"
55
- initial={{ opacity: 0, y: 10 }}
56
- animate={
57
- shouldAnimate
58
- ? { opacity: 1, y: 0 }
59
- : { opacity: 0, y: 10 }
60
- }
61
- transition={{
62
- duration,
63
- delay: i * delay,
64
- ease: [0.2, 0.65, 0.3, 0.9],
65
- }}
66
- >
67
- {char === " " ? "\u00A0" : char}
68
- </motion.span>
69
- ))}
86
+ {/* Read once, whole; the animated pieces are hidden from assistive tech */}
87
+ <span className="sr-only">{text}</span>
88
+ {units.map((unit, i) =>
89
+ /^\s+$/.test(unit) ? (
90
+ // Whitespace stays plain text, so it follows the element's
91
+ // white-space like the unanimated text: collapsed normally, kept
92
+ // (with its line breaks) under whitespace-pre-wrap.
93
+ <React.Fragment key={`space-${i}`}>{unit}</React.Fragment>
94
+ ) : (
95
+ <motion.span
96
+ aria-hidden="true"
97
+ key={`${unit}-${i}`}
98
+ className="inline-block"
99
+ initial={{ opacity: 0, y: 10 }}
100
+ animate={
101
+ shouldAnimate
102
+ ? { opacity: 1, y: 0 }
103
+ : { opacity: 0, y: 10 }
104
+ }
105
+ transition={{
106
+ duration,
107
+ delay: i * delay,
108
+ ease: [0.2, 0.65, 0.3, 0.9],
109
+ }}
110
+ >
111
+ {unit}
112
+ </motion.span>
113
+ )
114
+ )}
70
115
  </div>
71
116
  )
72
117
  }
@@ -114,8 +159,10 @@ export function WordReveal({
114
159
  className={cn("inline-block", className)}
115
160
  {...props}
116
161
  >
162
+ <span className="sr-only">{text}</span>
117
163
  {words.map((word, i) => (
118
164
  <motion.span
165
+ aria-hidden="true"
119
166
  key={`${word}-${i}`}
120
167
  className="inline-block"
121
168
  initial={{ opacity: 0, y: 20, filter: "blur(10px)" }}
@@ -3,6 +3,27 @@
3
3
  import * as React from "react"
4
4
  import { cn } from "../../../src/lib/utils"
5
5
 
6
+ /**
7
+ * Whether the user asks for reduced motion. It starts false, so the server and
8
+ * the first client render agree, then follows the setting as it changes.
9
+ */
10
+ function usePrefersReducedMotion() {
11
+ const [reduced, setReduced] = React.useState(false)
12
+ React.useEffect(() => {
13
+ if (typeof window.matchMedia !== "function") return
14
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)")
15
+ setReduced(query.matches)
16
+ const onChange = () => setReduced(query.matches)
17
+ query.addEventListener("change", onChange)
18
+ return () => query.removeEventListener("change", onChange)
19
+ }, [])
20
+ return reduced
21
+ }
22
+
23
+ /** The value, or the fallback when it is missing or blank (a cleared control, say). */
24
+ const filled = (value: string | undefined, fallback: string) =>
25
+ value && value.trim() ? value : fallback
26
+
6
27
  export interface TypewriterTextProps extends React.HTMLAttributes<HTMLSpanElement> {
7
28
  /** Text to type out */
8
29
  text: string
@@ -27,34 +48,35 @@ export function TypewriterText({
27
48
  speed = 50,
28
49
  delay = 0,
29
50
  cursor = true,
30
- cursorChar = "|",
51
+ cursorChar: cursorCharProp,
31
52
  loop = false,
32
53
  loopDelay = 2000,
33
54
  onComplete,
34
55
  className,
35
56
  ...props
36
57
  }: TypewriterTextProps) {
58
+ const cursorChar = filled(cursorCharProp, "|")
37
59
  const [displayText, setDisplayText] = React.useState("")
38
60
  const [isTyping, setIsTyping] = React.useState(false)
39
- const [showCursor, setShowCursor] = React.useState(cursor)
61
+ const prefersReducedMotion = usePrefersReducedMotion()
62
+ const showCursor = cursor && !prefersReducedMotion
40
63
 
41
- // Check for reduced motion preference
42
- const prefersReducedMotion = React.useMemo(() => {
43
- if (typeof window === "undefined") return false
44
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches
45
- }, [])
64
+ // Held in a ref: an inline callback is a new function every render, and as
65
+ // an effect dependency it restarted the typing each time the parent rendered.
66
+ const onCompleteRef = React.useRef(onComplete)
67
+ onCompleteRef.current = onComplete
46
68
 
47
69
  React.useEffect(() => {
48
70
  // If user prefers reduced motion, show full text immediately
49
71
  if (prefersReducedMotion) {
50
72
  setDisplayText(text)
51
- setShowCursor(false)
52
- onComplete?.()
73
+ onCompleteRef.current?.()
53
74
  return
54
75
  }
55
76
 
56
77
  let timeoutId: ReturnType<typeof setTimeout>
57
78
  let charIndex = 0
79
+ setDisplayText("")
58
80
  setIsTyping(true)
59
81
 
60
82
  const startTyping = () => {
@@ -65,7 +87,7 @@ export function TypewriterText({
65
87
  timeoutId = setTimeout(typeChar, speed)
66
88
  } else {
67
89
  setIsTyping(false)
68
- onComplete?.()
90
+ onCompleteRef.current?.()
69
91
 
70
92
  if (loop) {
71
93
  timeoutId = setTimeout(() => {
@@ -84,20 +106,20 @@ export function TypewriterText({
84
106
  timeoutId = setTimeout(startTyping, delay)
85
107
 
86
108
  return () => clearTimeout(timeoutId)
87
- }, [text, speed, delay, loop, loopDelay, onComplete, prefersReducedMotion])
109
+ }, [text, speed, delay, loop, loopDelay, prefersReducedMotion])
88
110
 
89
111
  // Cursor blink effect
90
112
  const [cursorVisible, setCursorVisible] = React.useState(true)
91
113
 
92
114
  React.useEffect(() => {
93
- if (!showCursor || prefersReducedMotion) return
115
+ if (!showCursor) return
94
116
 
95
117
  const blinkInterval = setInterval(() => {
96
118
  setCursorVisible((prev) => !prev)
97
119
  }, 530)
98
120
 
99
121
  return () => clearInterval(blinkInterval)
100
- }, [showCursor, prefersReducedMotion])
122
+ }, [showCursor])
101
123
 
102
124
  return (
103
125
  <span className={cn("inline", className)} {...props}>
@@ -105,7 +127,7 @@ export function TypewriterText({
105
127
  {showCursor && (
106
128
  <span
107
129
  className={cn(
108
- "ml-0.5 inline-block",
130
+ "ms-0.5 inline-block",
109
131
  cursorVisible ? "opacity-100" : "opacity-0",
110
132
  isTyping ? "" : "animate-pulse"
111
133
  )}
@@ -144,10 +166,7 @@ export function TypewriterWords({
144
166
  const [displayText, setDisplayText] = React.useState("")
145
167
  const [isDeleting, setIsDeleting] = React.useState(false)
146
168
 
147
- const prefersReducedMotion = React.useMemo(() => {
148
- if (typeof window === "undefined") return false
149
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches
150
- }, [])
169
+ const prefersReducedMotion = usePrefersReducedMotion()
151
170
 
152
171
  React.useEffect(() => {
153
172
  if (prefersReducedMotion) {
@@ -204,10 +223,10 @@ export function TypewriterWords({
204
223
  return (
205
224
  <span className={cn("inline", className)} {...props}>
206
225
  {displayText}
207
- {cursor && (
226
+ {cursor && !prefersReducedMotion && (
208
227
  <span
209
228
  className={cn(
210
- "ml-0.5 inline-block",
229
+ "ms-0.5 inline-block",
211
230
  cursorVisible ? "opacity-100" : "opacity-0"
212
231
  )}
213
232
  aria-hidden="true"