create-omg 0.4.30

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 (38) hide show
  1. package/README.md +14 -0
  2. package/dist/index.mjs +100 -0
  3. package/dist/template/AGENTS.md +49 -0
  4. package/dist/template/README.md +18 -0
  5. package/dist/template/_gitignore +24 -0
  6. package/dist/template/bun.lock +898 -0
  7. package/dist/template/eslint.config.js +23 -0
  8. package/dist/template/functions/.gitkeep +0 -0
  9. package/dist/template/index.html +20 -0
  10. package/dist/template/package.json +48 -0
  11. package/dist/template/public/favicon.svg +1 -0
  12. package/dist/template/public/icons/apple-touch-icon.png +0 -0
  13. package/dist/template/public/icons/pwa-192x192.png +0 -0
  14. package/dist/template/public/icons/pwa-512x512-maskable.png +0 -0
  15. package/dist/template/public/icons/pwa-512x512.png +0 -0
  16. package/dist/template/public/icons.svg +24 -0
  17. package/dist/template/schema.ts +5 -0
  18. package/dist/template/server/db.ts +103 -0
  19. package/dist/template/src/App.tsx +128 -0
  20. package/dist/template/src/components/ui/animated-number.tsx +102 -0
  21. package/dist/template/src/components/ui/animated-text.tsx +109 -0
  22. package/dist/template/src/components/ui/bottom-nav.tsx +131 -0
  23. package/dist/template/src/components/ui/button.tsx +84 -0
  24. package/dist/template/src/components/ui/card.tsx +70 -0
  25. package/dist/template/src/components/ui/otp-input.tsx +179 -0
  26. package/dist/template/src/components/ui/scroll-affordance.tsx +106 -0
  27. package/dist/template/src/components/ui/sound.ts +182 -0
  28. package/dist/template/src/components/ui/transitions.tsx +419 -0
  29. package/dist/template/src/components/ui/vibes-ui-styles.ts +388 -0
  30. package/dist/template/src/db.drizzle.ts +5 -0
  31. package/dist/template/src/index.css +1 -0
  32. package/dist/template/src/lib/utils.ts +6 -0
  33. package/dist/template/src/main.tsx +10 -0
  34. package/dist/template/tsconfig.app.json +30 -0
  35. package/dist/template/tsconfig.json +10 -0
  36. package/dist/template/tsconfig.node.json +24 -0
  37. package/dist/template/vite.config.ts +23 -0
  38. package/package.json +37 -0
@@ -0,0 +1,131 @@
1
+ // Floating bottom tab bar — the iOS-style pill switcher from the omg home
2
+ // screen, vendored for apps. A translucent blurred pill wrapped in a gradient
3
+ // white hairline that catches the light along its top edge (the way an iOS
4
+ // material does), with an active capsule that *slides* between tabs via a pure
5
+ // GPU translate.
6
+ //
7
+ // This is the presentational, controlled component: pass it your tabs, the
8
+ // active id, and an onSelect handler. It owns no routing — wire `active` /
9
+ // `onSelect` to your router or local state.
10
+ //
11
+ // const [tab, setTab] = useState("home")
12
+ // <BottomNav
13
+ // tabs={[
14
+ // { id: "home", label: "Home", Icon: Home },
15
+ // { id: "browse", label: "Browse", Icon: Compass },
16
+ // { id: "profile", label: "Profile", Icon: User },
17
+ // ]}
18
+ // active={tab}
19
+ // onSelect={setTab}
20
+ // />
21
+ //
22
+ // Self-contained: colours read the app's shadcn theme vars (--primary,
23
+ // --background, --muted-foreground) so it follows whatever preset the app uses.
24
+ // Vendored — edit freely.
25
+
26
+ import type { ComponentType, CSSProperties } from "react"
27
+
28
+ import { cn } from "@/lib/utils"
29
+
30
+ // Tab width and gap are fixed so the sliding capsule can be a pure translate —
31
+ // the only animated property (GPU-cheap, interruptible). TAB_GAP must match the
32
+ // `gap-1` between tab buttons.
33
+ const TAB_W = "4.5rem"
34
+ const TAB_GAP = "0.25rem"
35
+ // The omg motion curve (Ionic's iOS drawer easing).
36
+ const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"
37
+
38
+ // A whisper of haptic feedback on tap where the platform supports it (iOS
39
+ // Safari ignores it; Android/Chrome buzz ~10ms). No-op everywhere else.
40
+ function haptic() {
41
+ if (typeof navigator !== "undefined" && "vibrate" in navigator) {
42
+ try {
43
+ navigator.vibrate(8)
44
+ } catch {
45
+ /* feature-detected but disallowed (e.g. no user gesture) — ignore */
46
+ }
47
+ }
48
+ }
49
+
50
+ export type BottomNavItem<T extends string> = {
51
+ id: T
52
+ label: string
53
+ Icon: ComponentType<{ className?: string }>
54
+ // Visible but inert — rendered dimmed; taps don't fire onSelect.
55
+ disabled?: boolean
56
+ }
57
+
58
+ export function BottomNav<T extends string>({
59
+ tabs,
60
+ active,
61
+ onSelect,
62
+ className,
63
+ }: {
64
+ tabs: ReadonlyArray<BottomNavItem<T>>
65
+ active: T
66
+ onSelect: (id: T) => void
67
+ className?: string
68
+ }) {
69
+ const activeIndex = Math.max(
70
+ 0,
71
+ tabs.findIndex((t) => t.id === active),
72
+ )
73
+ return (
74
+ <div
75
+ className={cn(
76
+ "pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center pb-[calc(0.875rem+env(safe-area-inset-bottom))]",
77
+ className,
78
+ )}
79
+ >
80
+ {/* Gradient hairline: brightest on the top edge, fading down — the
81
+ border itself is the p-px ring around the blurred pill. */}
82
+ <div className="pointer-events-auto rounded-full bg-gradient-to-b from-white/70 via-white/25 to-white/10 p-px shadow-[0_8px_28px_oklch(0_0_0/14%)] dark:from-white/25 dark:via-white/10 dark:to-white/5">
83
+ <nav className="relative flex items-center gap-1 rounded-full bg-background/85 p-1.5 backdrop-blur-xl">
84
+ {/* Sliding capsule: translate by activeIndex × (tab width + gap). */}
85
+ <span
86
+ aria-hidden
87
+ className="absolute inset-y-1.5 left-1.5 w-[var(--tab-w)] rounded-full bg-primary/12 transition-transform duration-[260ms]"
88
+ style={
89
+ {
90
+ "--tab-w": TAB_W,
91
+ transitionTimingFunction: EASE,
92
+ transform: `translateX(calc(${activeIndex} * (${TAB_W} + ${TAB_GAP})))`,
93
+ } as CSSProperties
94
+ }
95
+ />
96
+ {tabs.map(({ id, label, Icon, disabled }) => {
97
+ const selected = id === active
98
+ return (
99
+ <button
100
+ key={id}
101
+ type="button"
102
+ aria-label={label}
103
+ aria-current={selected ? "page" : undefined}
104
+ aria-disabled={disabled || undefined}
105
+ onClick={() => {
106
+ if (disabled) return
107
+ haptic()
108
+ if (!selected) onSelect(id)
109
+ }}
110
+ className={cn(
111
+ "relative flex w-[var(--tab-w)] flex-col items-center gap-0.5 rounded-full px-4 py-1.5 transition-[color,transform] duration-200 ease-out",
112
+ disabled
113
+ ? "text-muted-foreground/40"
114
+ : selected
115
+ ? "text-primary active:scale-[0.96]"
116
+ : "text-muted-foreground hover:text-foreground active:scale-[0.96]",
117
+ )}
118
+ style={{ "--tab-w": TAB_W } as CSSProperties}
119
+ >
120
+ <Icon className="h-[18px] w-[18px]" />
121
+ <span className="text-[10.5px] font-medium tracking-[-0.01em]">
122
+ {label}
123
+ </span>
124
+ </button>
125
+ )
126
+ })}
127
+ </nav>
128
+ </div>
129
+ </div>
130
+ )
131
+ }
@@ -0,0 +1,84 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+ import { playSound, type SoundName } from "@/components/ui/sound"
5
+
6
+ // Squishy base Button — vendored, self-contained (no class-variance-authority
7
+ // and no @base-ui dependency, so it works in a bare app without extra installs).
8
+ //
9
+ // The iOS "squish": `active:scale-[0.97]` on press, eased with the omg motion
10
+ // curve, snapping back instantly on release (`active:transition-none`). Corners
11
+ // pair the baked `squircle` utility with `rounded-md` so they read as soft
12
+ // continuous corners on Chromium 139+ and fall back to normal rounding
13
+ // everywhere else.
14
+ //
15
+ // Opt-in press SOUND: pass `sound` (or `sound="success"` etc.) and the button
16
+ // plays a synthesized, zero-file Web Audio tick on press — the audio companion
17
+ // to the visual squish. Off by default (silence is the right default; sound on
18
+ // EVERY button is noise), gesture-driven so the browser lets it through, and it
19
+ // self-mutes under prefers-reduced-motion. See `./sound`.
20
+ //
21
+ // Every colour reads the app's shadcn theme variables (--primary, --secondary,
22
+ // …), so the button inherits whatever preset the app uses. Edit freely — it's
23
+ // vendored into your project, not imported from a package.
24
+
25
+ type Variant = "default" | "secondary" | "outline" | "ghost" | "destructive"
26
+ type Size = "default" | "sm" | "lg" | "icon"
27
+
28
+ const VARIANTS: Record<Variant, string> = {
29
+ default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
30
+ secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
31
+ outline:
32
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
33
+ ghost: "hover:bg-accent hover:text-accent-foreground",
34
+ destructive: "bg-destructive text-white hover:bg-destructive/90",
35
+ }
36
+
37
+ const SIZES: Record<Size, string> = {
38
+ default: "h-9 px-4 py-2",
39
+ sm: "h-8 gap-1.5 px-3 text-xs",
40
+ lg: "h-10 px-6",
41
+ icon: "size-9",
42
+ }
43
+
44
+ export interface ButtonProps
45
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {
46
+ variant?: Variant
47
+ size?: Size
48
+ /**
49
+ * Play a synthesized press sound on pointer-down. `true` uses the soft
50
+ * `"tap"` tick; pass a preset name (`"click"`, `"success"`, …) to pick
51
+ * another. Off by default. Fires on pointer-down so the tick lands with the
52
+ * press, not the release.
53
+ */
54
+ sound?: boolean | SoundName
55
+ }
56
+
57
+ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
58
+ function Button(
59
+ { className, variant = "default", size = "default", sound, onPointerDown, ...props },
60
+ ref,
61
+ ) {
62
+ const handlePointerDown = React.useCallback(
63
+ (event: React.PointerEvent<HTMLButtonElement>) => {
64
+ if (sound && !props.disabled) playSound(sound === true ? "tap" : sound)
65
+ onPointerDown?.(event)
66
+ },
67
+ [sound, props.disabled, onPointerDown],
68
+ )
69
+ return (
70
+ <button
71
+ ref={ref}
72
+ data-slot="button"
73
+ onPointerDown={handlePointerDown}
74
+ className={cn(
75
+ "inline-flex shrink-0 select-none items-center justify-center gap-2 rounded-md squircle text-sm font-medium whitespace-nowrap outline-none transition-[transform,background-color,box-shadow,border-color,color] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.97] active:transition-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
76
+ VARIANTS[variant],
77
+ SIZES[size],
78
+ className,
79
+ )}
80
+ {...props}
81
+ />
82
+ )
83
+ },
84
+ )
@@ -0,0 +1,70 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ // Squircle Card — shadcn-shaped (Card / CardHeader / CardTitle / CardDescription
6
+ // / CardContent / CardFooter) so it's a drop-in for anyone used to shadcn, with
7
+ // the iOS continuous-corner `squircle` on the root surface. Theme-var driven
8
+ // (bg-card / text-card-foreground), so it follows the app's preset. Vendored —
9
+ // edit freely.
10
+
11
+ function Card({ className, ...props }: React.ComponentProps<"div">) {
12
+ return (
13
+ <div
14
+ data-slot="card"
15
+ className={cn(
16
+ "flex flex-col gap-6 rounded-xl squircle border bg-card py-6 text-card-foreground shadow-sm",
17
+ className,
18
+ )}
19
+ {...props}
20
+ />
21
+ )
22
+ }
23
+
24
+ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
25
+ return (
26
+ <div
27
+ data-slot="card-header"
28
+ className={cn("flex flex-col gap-1.5 px-6", className)}
29
+ {...props}
30
+ />
31
+ )
32
+ }
33
+
34
+ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
35
+ return (
36
+ <div
37
+ data-slot="card-title"
38
+ className={cn("font-semibold leading-none tracking-tight", className)}
39
+ {...props}
40
+ />
41
+ )
42
+ }
43
+
44
+ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
45
+ return (
46
+ <div
47
+ data-slot="card-description"
48
+ className={cn("text-sm text-muted-foreground", className)}
49
+ {...props}
50
+ />
51
+ )
52
+ }
53
+
54
+ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
55
+ return (
56
+ <div data-slot="card-content" className={cn("px-6", className)} {...props} />
57
+ )
58
+ }
59
+
60
+ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
61
+ return (
62
+ <div
63
+ data-slot="card-footer"
64
+ className={cn("flex items-center px-6", className)}
65
+ {...props}
66
+ />
67
+ )
68
+ }
69
+
70
+ export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
@@ -0,0 +1,179 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+ import { playSound, type SoundName } from "@/components/ui/sound"
5
+
6
+ // <OtpInput> — segmented one-time-code field (the "type the 6-digit code"
7
+ // input). Vendored, self-contained (no input-otp / no @radix dependency), so
8
+ // it drops into a bare app with nothing to install.
9
+ //
10
+ // Why it's not N separate <input>s: this renders ONE real, focusable text
11
+ // input stretched invisibly across the field, with the digit cells painted
12
+ // beneath it. That single input keeps everything the platform gives you for
13
+ // free — native paste of a whole code, iOS/Android SMS autofill
14
+ // (`autocomplete="one-time-code"`), the numeric soft keyboard on mobile
15
+ // (`inputmode="numeric"`), and correct screen-reader semantics — while the
16
+ // cells are yours to style. Auto-advance and backspace "just work" because
17
+ // there's only one caret to move.
18
+ //
19
+ // Delight, on by the same rules as the rest of the kit:
20
+ // - Squircle continuous corners (paired with `rounded-md`, the fallback) so
21
+ // the cells match `Button`/`Card`.
22
+ // - The active cell shows a soft blinking caret; a filled field pops a subtle
23
+ // ring. Motion self-mutes under `prefers-reduced-motion`.
24
+ // - Opt-in tactile SOUND (`sound` prop): a quiet `tap` per digit and a
25
+ // `success` chime when the code completes — off by default (silence is the
26
+ // right default), gesture-driven, and self-muting under reduced-motion.
27
+ // See `./sound`.
28
+ //
29
+ // Controlled: drive `value` / `onChange` from state (mirrors how `VibesLogin`
30
+ // already tracks the code). `onComplete` fires once the field fills — wire your
31
+ // verify call there. Every colour reads the app's shadcn theme variables, so it
32
+ // inherits whatever preset the app uses. Edit freely — it's yours now.
33
+
34
+ export interface OtpInputProps {
35
+ /** The current code (controlled). Only allowed characters are ever stored. */
36
+ value: string
37
+ /** Called with the sanitized next value on every change. */
38
+ onChange: (value: string) => void
39
+ /** Fires once the field reaches `length`. Great place to auto-submit/verify. */
40
+ onComplete?: (value: string) => void
41
+ /** Number of cells. Defaults to 6 (the vibes email-code length). */
42
+ length?: number
43
+ /**
44
+ * Play a synthesized sound as the user types. `true` uses a soft `tap` per
45
+ * digit plus a `success` chime on completion; pass a preset name to use that
46
+ * tone for the per-digit tick instead. Off by default.
47
+ */
48
+ sound?: boolean | SoundName
49
+ disabled?: boolean
50
+ autoFocus?: boolean
51
+ /** Soft-keyboard hint. `"numeric"` (default) for codes; `"text"` for alnum. */
52
+ inputMode?: "numeric" | "text"
53
+ /**
54
+ * Which characters are accepted. Defaults to digits only. Anything typed or
55
+ * pasted that doesn't match is dropped (so pasting "123-456" yields "123456").
56
+ */
57
+ allowed?: RegExp
58
+ "aria-label"?: string
59
+ className?: string
60
+ /** Extra classes merged onto each cell (e.g. sizing, radius). */
61
+ cellClassName?: string
62
+ }
63
+
64
+ export function OtpInput({
65
+ value,
66
+ onChange,
67
+ onComplete,
68
+ length = 6,
69
+ sound = false,
70
+ disabled = false,
71
+ autoFocus = false,
72
+ inputMode = "numeric",
73
+ allowed = /[0-9]/,
74
+ "aria-label": ariaLabel = "Verification code",
75
+ className,
76
+ cellClassName,
77
+ }: OtpInputProps) {
78
+ const [focused, setFocused] = React.useState(false)
79
+ const inputRef = React.useRef<HTMLInputElement>(null)
80
+ // Guard so onComplete fires once per fill, not on every keystroke while full.
81
+ const completedRef = React.useRef(false)
82
+
83
+ const tapTone: SoundName = sound === true ? "tap" : (sound as SoundName)
84
+
85
+ const sanitize = React.useCallback(
86
+ (raw: string) =>
87
+ Array.from(raw)
88
+ .filter((ch) => allowed.test(ch))
89
+ .join("")
90
+ .slice(0, length),
91
+ [allowed, length],
92
+ )
93
+
94
+ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
95
+ const next = sanitize(e.target.value)
96
+ if (next === value) return
97
+ const grew = next.length > value.length
98
+ onChange(next)
99
+
100
+ if (next.length === length) {
101
+ if (!completedRef.current) {
102
+ completedRef.current = true
103
+ if (sound) playSound("success")
104
+ onComplete?.(next)
105
+ }
106
+ } else {
107
+ completedRef.current = false
108
+ if (grew && sound) playSound(tapTone)
109
+ }
110
+ }
111
+
112
+ // The active cell is the first empty slot, clamped to the last cell when full
113
+ // so the caret has somewhere to sit.
114
+ const activeIndex = Math.min(value.length, length - 1)
115
+ const cells = Array.from({ length }, (_, i) => value[i] ?? "")
116
+
117
+ return (
118
+ <div
119
+ className={cn("relative inline-flex w-full", className)}
120
+ onMouseDown={(e) => {
121
+ // Keep clicks anywhere in the field focusing the (invisible) input,
122
+ // even if they land between cells.
123
+ if (e.target !== inputRef.current) {
124
+ e.preventDefault()
125
+ inputRef.current?.focus()
126
+ }
127
+ }}
128
+ >
129
+ <div className="flex w-full items-center justify-center gap-2">
130
+ {cells.map((char, i) => {
131
+ const isActive = focused && i === activeIndex && !disabled
132
+ return (
133
+ <div
134
+ key={i}
135
+ data-slot="otp-cell"
136
+ data-active={isActive || undefined}
137
+ data-filled={char ? true : undefined}
138
+ className={cn(
139
+ "relative flex h-12 w-10 items-center justify-center rounded-md squircle border border-input bg-background text-xl font-medium tabular-nums shadow-sm transition-[color,box-shadow,border-color] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)]",
140
+ isActive && "border-ring ring-[3px] ring-ring/50 z-10",
141
+ disabled && "opacity-50",
142
+ cellClassName,
143
+ )}
144
+ >
145
+ {char || (
146
+ // Blinking caret on the active empty cell; motion off under
147
+ // prefers-reduced-motion (Tailwind's motion-reduce variant).
148
+ isActive ? (
149
+ <span
150
+ aria-hidden="true"
151
+ className="h-6 w-px animate-pulse bg-foreground motion-reduce:animate-none"
152
+ />
153
+ ) : null
154
+ )}
155
+ </div>
156
+ )
157
+ })}
158
+ </div>
159
+
160
+ <input
161
+ ref={inputRef}
162
+ value={value}
163
+ onChange={handleChange}
164
+ onFocus={() => setFocused(true)}
165
+ onBlur={() => setFocused(false)}
166
+ disabled={disabled}
167
+ autoFocus={autoFocus}
168
+ inputMode={inputMode}
169
+ pattern={inputMode === "numeric" ? "[0-9]*" : undefined}
170
+ autoComplete="one-time-code"
171
+ aria-label={ariaLabel}
172
+ maxLength={length}
173
+ // Invisible but real: fills the field, captures typing/paste/autofill,
174
+ // and owns the (hidden) caret so the platform handles selection for us.
175
+ className="absolute inset-0 h-full w-full cursor-default bg-transparent text-center text-transparent caret-transparent opacity-0 outline-none disabled:cursor-not-allowed"
176
+ />
177
+ </div>
178
+ )
179
+ }
@@ -0,0 +1,106 @@
1
+ // <ScrollAffordance> — a "there's more below" cue.
2
+ //
3
+ // Vendored, editable source (was @omg-dev/ui). Bobs while the watched container
4
+ // is at the top, fades the instant the user scrolls, and hides itself when
5
+ // there's nothing to scroll — so it's safe to render unconditionally.
6
+ // Self-contained: no Tailwind utility classes, no runtime dependency beyond
7
+ // React. Themes itself from your shadcn CSS variables (--muted-foreground)
8
+ // with light/dark fallbacks and honors prefers-reduced-motion.
9
+
10
+ import {
11
+ useEffect,
12
+ useLayoutEffect,
13
+ useState,
14
+ type CSSProperties,
15
+ type RefObject,
16
+ } from "react"
17
+ import { ensureStyles } from "@/components/ui/vibes-ui-styles"
18
+
19
+ // useLayoutEffect warns during SSR; fall back to useEffect on the server.
20
+ const useIsoLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect
21
+
22
+ export type ScrollAffordanceProps = {
23
+ /**
24
+ * The scroll container to watch. Omit to watch the window (the common case
25
+ * for a long landing page). Pass a ref to watch an overflowing panel.
26
+ */
27
+ scrollRef?: RefObject<HTMLElement | null>
28
+ /** Caption above the chevron. Pass `null` for a bare chevron. */
29
+ label?: string | null
30
+ /** Hide once scrolled this many px past the top. */
31
+ threshold?: number
32
+ className?: string
33
+ style?: CSSProperties
34
+ }
35
+
36
+ /**
37
+ * A self-aware "scroll for more" cue. It bobs while the watched container is
38
+ * at the top, fades away the moment the user scrolls, and hides itself
39
+ * entirely when there's nothing to scroll — so it's safe to render
40
+ * unconditionally.
41
+ */
42
+ export function ScrollAffordance({
43
+ scrollRef,
44
+ label = "Scroll",
45
+ threshold = 24,
46
+ className = "",
47
+ style,
48
+ }: ScrollAffordanceProps) {
49
+ useEffect(ensureStyles, [])
50
+ // Start hidden; the first measurement reveals it only if there's overflow.
51
+ const [hidden, setHidden] = useState(true)
52
+
53
+ useIsoLayoutEffect(() => {
54
+ if (typeof window === "undefined") return
55
+ const el = scrollRef?.current ?? null
56
+
57
+ const measure = () => {
58
+ const scrollTop = el ? el.scrollTop : window.scrollY
59
+ const scrollHeight = el
60
+ ? el.scrollHeight
61
+ : document.documentElement.scrollHeight
62
+ const clientHeight = el ? el.clientHeight : window.innerHeight
63
+ const scrollable = scrollHeight - clientHeight > threshold
64
+ setHidden(!scrollable || scrollTop > threshold)
65
+ }
66
+
67
+ const source: HTMLElement | Window = el ?? window
68
+ measure()
69
+ source.addEventListener("scroll", measure, { passive: true })
70
+ window.addEventListener("resize", measure)
71
+ // Content can grow after async data loads — re-measure on layout changes.
72
+ const ro =
73
+ typeof ResizeObserver !== "undefined"
74
+ ? new ResizeObserver(measure)
75
+ : null
76
+ if (ro) ro.observe(el ?? document.documentElement)
77
+
78
+ return () => {
79
+ source.removeEventListener("scroll", measure)
80
+ window.removeEventListener("resize", measure)
81
+ ro?.disconnect()
82
+ }
83
+ }, [scrollRef, threshold])
84
+
85
+ return (
86
+ <span
87
+ className={`vui vui-scroll ${className}`.trim()}
88
+ style={style}
89
+ data-hidden={hidden}
90
+ aria-hidden="true"
91
+ >
92
+ {label ? <span>{label}</span> : null}
93
+ <svg
94
+ className="vui-scroll-chevron"
95
+ viewBox="0 0 24 24"
96
+ fill="none"
97
+ stroke="currentColor"
98
+ strokeWidth="2"
99
+ strokeLinecap="round"
100
+ strokeLinejoin="round"
101
+ >
102
+ <path d="m6 9 6 6 6-6" />
103
+ </svg>
104
+ </span>
105
+ )
106
+ }