@a4ui/core 0.11.1 → 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.
Files changed (151) hide show
  1. package/README.md +80 -37
  2. package/dist/elements.css +79 -9
  3. package/dist/full.css +3112 -0
  4. package/dist/index.d.ts +13 -1
  5. package/dist/index.js +3392 -2537
  6. package/dist/lib/flyToCart.d.ts +25 -0
  7. package/dist/ui/Curtain.d.ts +44 -0
  8. package/dist/ui/Expandable.d.ts +34 -0
  9. package/dist/ui/FillText.d.ts +18 -0
  10. package/dist/ui/HoldToConfirm.d.ts +24 -0
  11. package/dist/ui/LoadingDots.d.ts +20 -0
  12. package/dist/ui/MultiStateBadge.d.ts +23 -0
  13. package/dist/ui/NotificationStack.d.ts +32 -0
  14. package/dist/ui/NowPlaying.d.ts +21 -0
  15. package/dist/ui/Parallax.d.ts +21 -0
  16. package/dist/ui/ScrambleText.d.ts +22 -0
  17. package/dist/ui/TextReveal.d.ts +23 -0
  18. package/package.json +8 -1
  19. package/src/charts/BarChart.tsx +55 -0
  20. package/src/charts/DonutChart.tsx +127 -0
  21. package/src/charts/Sparkline.tsx +101 -0
  22. package/src/charts/index.ts +5 -0
  23. package/src/commerce/CartLine.tsx +84 -0
  24. package/src/commerce/CartSummary.tsx +70 -0
  25. package/src/commerce/FilterGroup.tsx +70 -0
  26. package/src/commerce/PriceTag.tsx +62 -0
  27. package/src/commerce/ProductCard.tsx +121 -0
  28. package/src/commerce/ProductGrid.tsx +27 -0
  29. package/src/commerce/QuantityStepper.tsx +59 -0
  30. package/src/commerce/index.ts +10 -0
  31. package/src/elements.tsx +167 -0
  32. package/src/index.ts +186 -0
  33. package/src/layout/AppShell.tsx +102 -0
  34. package/src/layout/ChristmasBackground.tsx +177 -0
  35. package/src/layout/EffectsToggle.tsx +41 -0
  36. package/src/layout/NavGroup.tsx +45 -0
  37. package/src/layout/SnowScenery.tsx +76 -0
  38. package/src/layout/SpaceBackground.tsx +459 -0
  39. package/src/layout/ThemeToggle.tsx +40 -0
  40. package/src/layout/ThemedScenery.tsx +179 -0
  41. package/src/layout/sceneEffects.ts +49 -0
  42. package/src/lib/cn.ts +17 -0
  43. package/src/lib/effects.ts +71 -0
  44. package/src/lib/flyToCart.ts +103 -0
  45. package/src/lib/media.ts +27 -0
  46. package/src/lib/motion.ts +191 -0
  47. package/src/lib/theme.ts +113 -0
  48. package/src/lib/virtual.ts +33 -0
  49. package/src/styles/space.css +510 -0
  50. package/src/styles/tokens.css +213 -0
  51. package/src/themes/index.ts +102 -0
  52. package/src/themes/palettes.ts +368 -0
  53. package/src/ui/Accordion.tsx +65 -0
  54. package/src/ui/Affix.tsx +72 -0
  55. package/src/ui/Alert.tsx +55 -0
  56. package/src/ui/AlertDialog.tsx +58 -0
  57. package/src/ui/Anchor.tsx +100 -0
  58. package/src/ui/Avatar.tsx +34 -0
  59. package/src/ui/AvatarGroup.tsx +55 -0
  60. package/src/ui/BackToTop.tsx +51 -0
  61. package/src/ui/Badge.tsx +46 -0
  62. package/src/ui/BottomNavigation.tsx +65 -0
  63. package/src/ui/Breadcrumb.tsx +64 -0
  64. package/src/ui/Button.tsx +49 -0
  65. package/src/ui/Calendar.tsx +40 -0
  66. package/src/ui/CalendarHeatmap.tsx +180 -0
  67. package/src/ui/Card.tsx +84 -0
  68. package/src/ui/Carousel.tsx +121 -0
  69. package/src/ui/Cascader.tsx +158 -0
  70. package/src/ui/Checkbox.tsx +35 -0
  71. package/src/ui/Clock.tsx +221 -0
  72. package/src/ui/Collapse.tsx +48 -0
  73. package/src/ui/ColorPicker.tsx +89 -0
  74. package/src/ui/Combobox.tsx +67 -0
  75. package/src/ui/Command.tsx +158 -0
  76. package/src/ui/Comment.tsx +91 -0
  77. package/src/ui/ContextMenu.tsx +58 -0
  78. package/src/ui/Countdown.tsx +123 -0
  79. package/src/ui/Curtain.tsx +348 -0
  80. package/src/ui/DataGrid.tsx +177 -0
  81. package/src/ui/DateField.tsx +177 -0
  82. package/src/ui/DateRangePicker.tsx +204 -0
  83. package/src/ui/DateTimeField.tsx +68 -0
  84. package/src/ui/Descriptions.tsx +56 -0
  85. package/src/ui/Drawer.tsx +72 -0
  86. package/src/ui/Dropdown.tsx +71 -0
  87. package/src/ui/Dropzone.tsx +88 -0
  88. package/src/ui/Empty.tsx +53 -0
  89. package/src/ui/Expandable.tsx +229 -0
  90. package/src/ui/FileUpload.tsx +198 -0
  91. package/src/ui/FillText.tsx +63 -0
  92. package/src/ui/FloatingActionButton.tsx +48 -0
  93. package/src/ui/Form.tsx +108 -0
  94. package/src/ui/Highlight.tsx +53 -0
  95. package/src/ui/HoldToConfirm.tsx +141 -0
  96. package/src/ui/HoverCard.tsx +42 -0
  97. package/src/ui/Image.tsx +66 -0
  98. package/src/ui/Input.tsx +38 -0
  99. package/src/ui/Kbd.tsx +25 -0
  100. package/src/ui/List.tsx +71 -0
  101. package/src/ui/LoadingDots.tsx +75 -0
  102. package/src/ui/Marquee.tsx +48 -0
  103. package/src/ui/Mentions.tsx +170 -0
  104. package/src/ui/Meter.tsx +57 -0
  105. package/src/ui/Modal.tsx +106 -0
  106. package/src/ui/MultiSelect.tsx +236 -0
  107. package/src/ui/MultiStateBadge.tsx +93 -0
  108. package/src/ui/NotificationCenter.tsx +103 -0
  109. package/src/ui/NotificationStack.tsx +104 -0
  110. package/src/ui/NowPlaying.tsx +100 -0
  111. package/src/ui/NumberInput.tsx +48 -0
  112. package/src/ui/PageHeader.tsx +60 -0
  113. package/src/ui/Pagination.tsx +62 -0
  114. package/src/ui/Parallax.tsx +51 -0
  115. package/src/ui/Popover.tsx +42 -0
  116. package/src/ui/Portal.tsx +34 -0
  117. package/src/ui/Progress.tsx +55 -0
  118. package/src/ui/RadioGroup.tsx +65 -0
  119. package/src/ui/Rating.tsx +98 -0
  120. package/src/ui/Result.tsx +75 -0
  121. package/src/ui/RingProgress.tsx +75 -0
  122. package/src/ui/ScrambleText.tsx +95 -0
  123. package/src/ui/SegmentedControl.tsx +61 -0
  124. package/src/ui/Select.tsx +55 -0
  125. package/src/ui/Separator.tsx +39 -0
  126. package/src/ui/Skeleton.tsx +23 -0
  127. package/src/ui/Slider.tsx +57 -0
  128. package/src/ui/Sortable.tsx +174 -0
  129. package/src/ui/SpeedDial.tsx +85 -0
  130. package/src/ui/Spinner.tsx +33 -0
  131. package/src/ui/Splitter.tsx +115 -0
  132. package/src/ui/Stat.tsx +74 -0
  133. package/src/ui/Stepper.tsx +134 -0
  134. package/src/ui/Switch.tsx +44 -0
  135. package/src/ui/Table.tsx +128 -0
  136. package/src/ui/Tabs.tsx +72 -0
  137. package/src/ui/TagInput.tsx +85 -0
  138. package/src/ui/TextReveal.tsx +93 -0
  139. package/src/ui/Textarea.tsx +38 -0
  140. package/src/ui/TimeField.tsx +298 -0
  141. package/src/ui/Timeline.tsx +89 -0
  142. package/src/ui/Toast.tsx +68 -0
  143. package/src/ui/Toggle.tsx +41 -0
  144. package/src/ui/ToggleGroup.tsx +59 -0
  145. package/src/ui/Tooltip.tsx +42 -0
  146. package/src/ui/Tour.tsx +197 -0
  147. package/src/ui/Transfer.tsx +112 -0
  148. package/src/ui/Tree.tsx +100 -0
  149. package/src/ui/TreeSelect.tsx +151 -0
  150. package/src/ui/VirtualList.tsx +81 -0
  151. package/src/ui/internal/CalendarCore.tsx +276 -0
@@ -0,0 +1,58 @@
1
+ // Right-click menu on Kobalte's ContextMenu primitive. `children` is the target.
2
+ import { ContextMenu as KContextMenu } from '@kobalte/core/context-menu'
3
+ import type { JSX, ParentProps } from 'solid-js'
4
+ import { For } from 'solid-js'
5
+
6
+ import { cn } from '../lib/cn'
7
+
8
+ /** A single actionable row rendered by {@link ContextMenu}. */
9
+ export interface ContextMenuItem {
10
+ label: string
11
+ /** Called when the item is chosen (click or keyboard activation). */
12
+ onSelect: () => void
13
+ /** Styles the label as a destructive action (e.g. "Delete"). */
14
+ destructive?: boolean
15
+ disabled?: boolean
16
+ }
17
+
18
+ interface ContextMenuProps extends ParentProps {
19
+ items: ContextMenuItem[]
20
+ class?: string
21
+ }
22
+
23
+ /**
24
+ * Right-click (or long-press) menu built on Kobalte's `ContextMenu`
25
+ * primitive. `children` is the element that acts as the trigger/target area.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <ContextMenu items={[{ label: 'Rename', onSelect: rename }, { label: 'Delete', onSelect: remove, destructive: true }]}>
30
+ * <div class="rounded border p-4">Right-click me</div>
31
+ * </ContextMenu>
32
+ * ```
33
+ */
34
+ export function ContextMenu(props: ContextMenuProps): JSX.Element {
35
+ return (
36
+ <KContextMenu>
37
+ <KContextMenu.Trigger class={cn('inline-flex', props.class)}>{props.children}</KContextMenu.Trigger>
38
+ <KContextMenu.Portal>
39
+ <KContextMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
40
+ <For each={props.items}>
41
+ {(item) => (
42
+ <KContextMenu.Item
43
+ class={cn(
44
+ 'cursor-pointer select-none rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
45
+ item.destructive && 'text-destructive',
46
+ )}
47
+ disabled={item.disabled}
48
+ onSelect={item.onSelect}
49
+ >
50
+ {item.label}
51
+ </KContextMenu.Item>
52
+ )}
53
+ </For>
54
+ </KContextMenu.Content>
55
+ </KContextMenu.Portal>
56
+ </KContextMenu>
57
+ )
58
+ }
@@ -0,0 +1,123 @@
1
+ // Live countdown to a target Date, rendered as four Days/Hours/Mins/Secs cells.
2
+ // A single setInterval (started in onMount, cleared in onCleanup) ticks a signal
3
+ // once per second; remaining ms is clamped at 0 and `onComplete` fires exactly
4
+ // once when the target is reached. Each digit is an odometer-style roller: the
5
+ // column of 0–9 slides so the active digit sits in the viewport, so a decreasing
6
+ // countdown rolls the new number in from the top — like an old flip clock.
7
+ //
8
+ // Digit/cell slots use <Index> (keyed by position, not value) so they persist
9
+ // across ticks and the CSS transform transition actually animates instead of the
10
+ // node being torn down and recreated each second.
11
+ //
12
+ // The target instant is captured once (createMemo): a parent JSX expression like
13
+ // `to={new Date(Date.now() + X)}` compiles to a getter Solid re-evaluates on each
14
+ // access, which would otherwise pin "remaining" at X forever. Memoizing reads it
15
+ // a single time (and still updates if `to` is backed by a real signal).
16
+ import { createMemo, createSignal, Index, onCleanup, onMount, type JSX } from 'solid-js'
17
+
18
+ import { cn } from '../lib/cn'
19
+
20
+ export interface CountdownProps {
21
+ /** Target moment the countdown runs toward. */
22
+ to: Date
23
+ /** Called once, the moment the countdown reaches zero. */
24
+ onComplete?: () => void
25
+ class?: string
26
+ }
27
+
28
+ const MS_PER_SECOND = 1000
29
+ const MS_PER_MINUTE = 60 * MS_PER_SECOND
30
+ const MS_PER_HOUR = 60 * MS_PER_MINUTE
31
+ const MS_PER_DAY = 24 * MS_PER_HOUR
32
+ const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
33
+
34
+ const pad = (n: number): string => String(n).padStart(2, '0')
35
+
36
+ /** One odometer digit: a 0–9 column shifted so `char`'s row fills the viewport. */
37
+ function Digit(props: { char: string }): JSX.Element {
38
+ const digit = () => (props.char >= '0' && props.char <= '9' ? Number(props.char) : 0)
39
+ return (
40
+ <span class="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-top">
41
+ <span
42
+ class="absolute inset-x-0 top-0 flex flex-col transition-transform duration-500 ease-out"
43
+ style={{ transform: `translateY(${digit() * -10}%)` }}
44
+ >
45
+ <Index each={DIGITS}>
46
+ {(d) => <span class="block h-[1em] text-center leading-none">{d()}</span>}
47
+ </Index>
48
+ </span>
49
+ </span>
50
+ )
51
+ }
52
+
53
+ /** Render a value string as a row of rolling digits (stable slots per position). */
54
+ function Rollers(props: { value: string }): JSX.Element {
55
+ const chars = () => props.value.split('')
56
+ return (
57
+ <span class="flex text-2xl font-bold tabular-nums text-foreground">
58
+ <Index each={chars()}>{(char) => <Digit char={char()} />}</Index>
59
+ </span>
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Live countdown to `props.to`, shown as four cells (Days / Hours / Mins /
65
+ * Secs). Updates every second and calls `props.onComplete` once when it hits
66
+ * zero. Each digit rolls like an old flip clock as it changes. Hours, minutes,
67
+ * and seconds are zero-padded to two digits.
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * <Countdown to={new Date(Date.now() + 86_400_000)} onComplete={() => launch()} />
72
+ * ```
73
+ */
74
+ export function Countdown(props: CountdownProps): JSX.Element {
75
+ // Read the target instant once (see file header on why this must be memoized).
76
+ const target = createMemo(() => props.to.getTime())
77
+ const remainingOf = () => Math.max(0, target() - Date.now())
78
+ const [remaining, setRemaining] = createSignal(remainingOf())
79
+
80
+ let completed = false
81
+ const notifyIfDone = (ms: number) => {
82
+ if (ms <= 0 && !completed) {
83
+ completed = true
84
+ props.onComplete?.()
85
+ }
86
+ }
87
+ // eslint-disable-next-line solid/reactivity -- one-time check at setup for an already-elapsed target
88
+ notifyIfDone(remaining())
89
+
90
+ onMount(() => {
91
+ const id = setInterval(() => {
92
+ const ms = remainingOf()
93
+ setRemaining(ms)
94
+ notifyIfDone(ms)
95
+ }, MS_PER_SECOND)
96
+ onCleanup(() => clearInterval(id))
97
+ })
98
+
99
+ const days = () => Math.floor(remaining() / MS_PER_DAY)
100
+ const hours = () => Math.floor((remaining() % MS_PER_DAY) / MS_PER_HOUR)
101
+ const minutes = () => Math.floor((remaining() % MS_PER_HOUR) / MS_PER_MINUTE)
102
+ const seconds = () => Math.floor((remaining() % MS_PER_MINUTE) / MS_PER_SECOND)
103
+
104
+ const cells = (): { label: string; value: string }[] => [
105
+ { label: 'Days', value: String(days()) },
106
+ { label: 'Hours', value: pad(hours()) },
107
+ { label: 'Mins', value: pad(minutes()) },
108
+ { label: 'Secs', value: pad(seconds()) },
109
+ ]
110
+
111
+ return (
112
+ <div class={cn('flex gap-4', props.class)}>
113
+ <Index each={cells()}>
114
+ {(cell) => (
115
+ <div class="flex flex-col items-center">
116
+ <Rollers value={cell().value} />
117
+ <span class="text-[10px] uppercase tracking-wide text-muted-foreground">{cell().label}</span>
118
+ </div>
119
+ )}
120
+ </Index>
121
+ </div>
122
+ )
123
+ }
@@ -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
+ }