@a4ui/core 0.15.0 → 0.17.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.
package/dist/styles.css CHANGED
@@ -162,6 +162,72 @@ body {
162
162
  animation: toast-out 0.18s ease-in;
163
163
  }
164
164
 
165
+ /* Skeleton shimmer — a light band sweeping across the placeholder (opt-in
166
+ alternative to the default pulse). The band color is token-driven so it reads
167
+ on every theme; the sweep is a pure CSS animation (no engine). */
168
+ @keyframes skeleton-shimmer {
169
+ 100% {
170
+ transform: translateX(100%);
171
+ }
172
+ }
173
+ .skeleton-shimmer {
174
+ position: relative;
175
+ overflow: hidden;
176
+ }
177
+ .skeleton-shimmer::after {
178
+ content: '';
179
+ position: absolute;
180
+ inset: 0;
181
+ transform: translateX(-100%);
182
+ background: linear-gradient(90deg, transparent, hsl(var(--foreground) / 0.08), transparent);
183
+ animation: skeleton-shimmer 1.5s ease-in-out infinite;
184
+ }
185
+
186
+ /* Accordion open/close — animate the panel height using Kobalte's measured
187
+ `--kb-accordion-content-height` var (presence keyed off data-expanded/closed,
188
+ animationend-driven like Modal). Engine-free. */
189
+ @keyframes accordion-down {
190
+ from {
191
+ height: 0;
192
+ }
193
+ to {
194
+ height: var(--kb-accordion-content-height);
195
+ }
196
+ }
197
+ @keyframes accordion-up {
198
+ from {
199
+ height: var(--kb-accordion-content-height);
200
+ }
201
+ to {
202
+ height: 0;
203
+ }
204
+ }
205
+ .accordion-content {
206
+ overflow: hidden;
207
+ }
208
+ .accordion-content[data-expanded] {
209
+ animation: accordion-down 0.2s ease-out;
210
+ }
211
+ .accordion-content[data-closed] {
212
+ animation: accordion-up 0.2s ease-out;
213
+ }
214
+
215
+ /* List stagger — rows fade/slide in on mount, delayed per index (set inline as
216
+ `animation-delay`). Opt-in via <List stagger>. */
217
+ @keyframes list-row-in {
218
+ from {
219
+ opacity: 0;
220
+ transform: translateY(6px);
221
+ }
222
+ to {
223
+ opacity: 1;
224
+ transform: none;
225
+ }
226
+ }
227
+ .list-row-stagger {
228
+ animation: list-row-in 0.35s both ease-out;
229
+ }
230
+
165
231
  /* Table row enter/exit fade. */
166
232
  .row-enter-active,
167
233
  .row-exit-active {
@@ -4,6 +4,12 @@ export type BadgeTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info';
4
4
  interface BadgeProps extends ParentProps {
5
5
  /** Visual/semantic tone. Defaults to `'neutral'`. */
6
6
  tone?: BadgeTone;
7
+ /**
8
+ * Show a pinging dot before the label for "live"/"recording"/"online"
9
+ * states. Tinted with the tone's color (`currentColor`); pure CSS
10
+ * (`animate-ping`), reduced-motion aware.
11
+ */
12
+ pulse?: boolean;
7
13
  class?: string;
8
14
  }
9
15
  /**
@@ -7,6 +7,12 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
7
7
  class?: string;
8
8
  /** Defaults to "button" so action buttons inside a form never submit it by accident. */
9
9
  type?: 'button' | 'submit' | 'reset';
10
+ /**
11
+ * Material-style click ripple from the press position. Engine-free (Web
12
+ * Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
13
+ * costs nothing when off.
14
+ */
15
+ ripple?: boolean;
10
16
  }
11
17
  /**
12
18
  * Base button primitive: a plain `<button>` with A4ui's variants, focus ring,
@@ -15,6 +21,7 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
15
21
  * @example
16
22
  * ```tsx
17
23
  * <Button variant="outline" onClick={() => save()}>Save</Button>
24
+ * <Button ripple onClick={() => save()}>Save</Button> // with a click ripple
18
25
  * ```
19
26
  */
20
27
  export declare function Button(props: ButtonProps): JSX.Element;
package/dist/ui/Card.d.ts CHANGED
@@ -10,15 +10,29 @@ interface CardProps extends DivProps {
10
10
  * for glass cards so the look is uniform; pass `glow={false}` to opt out.
11
11
  */
12
12
  glow?: boolean;
13
+ /**
14
+ * 3D tilt toward the cursor on hover — the same primitive as `<TiltCard>`,
15
+ * baked in ({@link attachTilt}). Engine-free and reduced-motion aware.
16
+ */
17
+ tilt?: boolean;
18
+ /**
19
+ * Soft radial glow following the cursor inside the card — the same primitive
20
+ * as `<Spotlight>`, baked in ({@link attachSpotlight}). Engine-free and
21
+ * reduced-motion aware.
22
+ */
23
+ spotlight?: boolean;
13
24
  }
14
25
  /**
15
26
  * Container surface for grouping related content, with an optional frosted
16
27
  * "space glass" look. Compose with {@link CardHeader}, {@link CardTitle}, and
17
28
  * {@link CardContent}.
18
29
  *
30
+ * `tilt` and `spotlight` bake in the cursor interactions from the Motion
31
+ * category — no wrapper components needed.
32
+ *
19
33
  * @example
20
34
  * ```tsx
21
- * <Card glass>
35
+ * <Card glass tilt spotlight>
22
36
  * <CardHeader>
23
37
  * <CardTitle>Usage</CardTitle>
24
38
  * </CardHeader>
@@ -4,6 +4,12 @@ export interface CarouselProps {
4
4
  slides: JSX.Element[];
5
5
  /** Auto-advance interval in ms. Omit to disable autoplay (also skipped under reduced motion / hover). */
6
6
  autoplayMs?: number;
7
+ /**
8
+ * Drag/touch to swipe between slides (in addition to arrows, dots, and arrow
9
+ * keys). The track follows the pointer and snaps on release — pure CSS, no
10
+ * engine. @default true
11
+ */
12
+ swipe?: boolean;
7
13
  class?: string;
8
14
  }
9
15
  /**
@@ -9,6 +9,12 @@ export interface FloatingActionButtonProps {
9
9
  onClick?: () => void;
10
10
  /** Screen corner to anchor to. Defaults to `'bottom-right'`. */
11
11
  position?: FloatingActionButtonPosition;
12
+ /**
13
+ * Material-style click ripple from the press position. Engine-free (Web
14
+ * Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
15
+ * costs nothing when off.
16
+ */
17
+ ripple?: boolean;
12
18
  class?: string;
13
19
  }
14
20
  /**
@@ -5,6 +5,12 @@ export interface ImageProps {
5
5
  class?: string;
6
6
  /** When true, clicking the image opens a zoomable lightbox. @default true */
7
7
  preview?: boolean;
8
+ /**
9
+ * Reveal the thumbnail with a blur-up: it starts blurred and slightly scaled,
10
+ * then eases to sharp once the image finishes loading. Pure CSS transition,
11
+ * no-op under reduced motion. @default false
12
+ */
13
+ blurUp?: boolean;
8
14
  }
9
15
  /**
10
16
  * Lazy-loaded content image. Unless `preview` is disabled, the thumbnail is a
package/dist/ui/List.d.ts CHANGED
@@ -9,6 +9,11 @@ export interface ListItem {
9
9
  }
10
10
  interface ListProps {
11
11
  items: ListItem[];
12
+ /**
13
+ * Cascade the rows in on mount — each row fades/slides up staggered by its
14
+ * index. Pure CSS (`list-row-stagger`), reduced-motion aware. @default false
15
+ */
16
+ stagger?: boolean;
12
17
  class?: string;
13
18
  }
14
19
  export type { ListProps };
@@ -7,6 +7,17 @@ export interface RippleProps {
7
7
  opacity?: number;
8
8
  class?: string;
9
9
  }
10
+ /**
11
+ * Spawns one Material-style ripple inside `container` at the pointer's
12
+ * position, expanding to cover the container and fading out, then removes
13
+ * itself. The container must be `position: relative; overflow: hidden`.
14
+ * Engine-free (Web Animations API); no-op under reduced motion. This is the
15
+ * primitive behind {@link Ripple} and `<Button ripple>`.
16
+ */
17
+ export declare function spawnRipple(container: HTMLElement, event: PointerEvent, opts?: {
18
+ color?: string;
19
+ opacity?: number;
20
+ }): void;
10
21
  /**
11
22
  * Wraps `children` in a `position: relative; overflow: hidden` layer that
12
23
  * spawns a Material-style ripple — a circle centered on the pointer's down
@@ -15,10 +26,12 @@ export interface RippleProps {
15
26
  * itself once its animation finishes. A no-op under reduced motion: children
16
27
  * still render, but no ripple ever spawns.
17
28
  *
29
+ * Buttons don't need the wrapper — use `<Button ripple>` instead.
30
+ *
18
31
  * @example
19
32
  * ```tsx
20
33
  * <Ripple color="white" opacity={0.25}>
21
- * <button class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</button>
34
+ * <div class="rounded-lg bg-primary px-4 py-2 text-primary-foreground">Save</div>
22
35
  * </Ripple>
23
36
  * ```
24
37
  */
@@ -2,15 +2,22 @@ import { JSX } from 'solid-js';
2
2
  interface SkeletonProps {
3
3
  /** Size/shape the placeholder to match the content it stands in for, e.g. `"h-4 w-32 rounded-full"`. */
4
4
  class?: string;
5
+ /**
6
+ * Use a light band sweeping across the placeholder instead of the default
7
+ * pulse. Pure CSS (`.skeleton-shimmer`), token-tinted, reduced-motion aware.
8
+ */
9
+ shimmer?: boolean;
5
10
  }
6
11
  /**
7
- * Pulsing placeholder block for content that is still loading. Plain `div`,
8
- * no Kobalte primitive — size it via `class` to match the shape of the real
9
- * content (text line, avatar, card, etc.).
12
+ * Placeholder block for content that is still loading. Plain `div`, no Kobalte
13
+ * primitive — size it via `class` to match the shape of the real content (text
14
+ * line, avatar, card, etc.). Pulses by default; pass `shimmer` for a sweeping
15
+ * light band instead.
10
16
  *
11
17
  * @example
12
18
  * ```tsx
13
19
  * <Skeleton class="h-4 w-48" />
20
+ * <Skeleton shimmer class="h-4 w-48" /> // sweeping band instead of pulse
14
21
  * ```
15
22
  */
16
23
  export declare function Skeleton(props: SkeletonProps): JSX.Element;
@@ -7,11 +7,24 @@ export interface SpotlightProps {
7
7
  size?: number;
8
8
  class?: string;
9
9
  }
10
+ /**
11
+ * Appends a cursor-following radial-glow overlay inside `el` (which must be
12
+ * `position: relative; overflow: hidden`) and binds the pointer handlers.
13
+ * Returns a cleanup that unbinds and removes the overlay. Engine-free; no-op
14
+ * under reduced motion. This is the primitive behind {@link Spotlight} and
15
+ * `<Card spotlight>`.
16
+ */
17
+ export declare function attachSpotlight(el: HTMLElement, opts?: {
18
+ color?: string;
19
+ size?: number;
20
+ }): () => void;
10
21
  /**
11
22
  * Wraps `children` in a `position: relative; overflow: hidden` layer with a
12
23
  * radial glow that tracks the cursor, fading in on pointerenter and out on
13
- * pointerleave. Children render above the glow. Respects
14
- * `prefers-reduced-motion` (renders children with no glow at all).
24
+ * pointerleave. Children render below the (pointer-transparent) glow.
25
+ * Respects `prefers-reduced-motion` (renders children with no glow at all).
26
+ *
27
+ * A4ui's own `Card` can do this without the wrapper — `<Card spotlight>`.
15
28
  *
16
29
  * @example
17
30
  * ```tsx
@@ -5,12 +5,25 @@ export interface TiltCardProps {
5
5
  max?: number;
6
6
  class?: string;
7
7
  }
8
+ /**
9
+ * Binds a cursor-following 3D tilt: pointer moves over `listenEl` rotate
10
+ * `animEl` toward the cursor (clamped to `max` degrees) with a slight lift,
11
+ * easing back flat on pointerleave. Returns a cleanup that unbinds and clears
12
+ * the transform. Engine-free (CSS transitions); no-op under reduced motion.
13
+ * This is the primitive behind {@link TiltCard} and `<Card tilt>` — pass the
14
+ * same element twice to tilt the element the pointer is over.
15
+ */
16
+ export declare function attachTilt(listenEl: HTMLElement, animEl: HTMLElement, opts?: {
17
+ max?: number;
18
+ }): () => void;
8
19
  /**
9
20
  * Wraps `children` in a card that tilts in 3D toward the cursor: as the
10
21
  * pointer moves within its bounds, the card rotates on both axes (clamped to
11
- * `max` degrees) and lifts slightly, springing back flat on pointerleave.
22
+ * `max` degrees) and lifts slightly, easing back flat on pointerleave.
12
23
  * Respects `prefers-reduced-motion` (renders static).
13
24
  *
25
+ * A4ui's own `Card` can do this without the wrapper — `<Card tilt>`.
26
+ *
14
27
  * @example
15
28
  * ```tsx
16
29
  * <TiltCard max={12}>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a4ui/core",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "A4ui — Spatial Glass design system & component library for SolidJS (Kobalte behavior + Tailwind glass tokens + motion).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,6 +37,16 @@ export interface ProductCardProps {
37
37
  currency?: string
38
38
  locale?: string
39
39
  onAddToCart?: () => void
40
+ /**
41
+ * 3D hover tilt toward the cursor — forwarded to the underlying {@link Card}
42
+ * ({@link attachTilt}). Engine-free and reduced-motion aware.
43
+ */
44
+ tilt?: boolean
45
+ /**
46
+ * Cursor-following radial glow inside the card — forwarded to the underlying
47
+ * {@link Card} ({@link attachSpotlight}). Engine-free and reduced-motion aware.
48
+ */
49
+ spotlight?: boolean
40
50
  class?: string
41
51
  }
42
52
 
@@ -72,7 +82,13 @@ function inferTone(badge: string | undefined, tone: ProductBadgeTone | undefined
72
82
  */
73
83
  export function ProductCard(props: ProductCardProps): JSX.Element {
74
84
  return (
75
- <Card glass glow class={cn('flex h-full flex-col overflow-hidden p-4', props.class)}>
85
+ <Card
86
+ glass
87
+ glow
88
+ tilt={props.tilt}
89
+ spotlight={props.spotlight}
90
+ class={cn('flex h-full flex-col overflow-hidden p-4', props.class)}
91
+ >
76
92
  <div class="relative aspect-square overflow-hidden rounded-lg bg-muted">
77
93
  <img src={props.image} alt={props.title} loading="lazy" class="h-full w-full object-cover" />
78
94
  <Show when={props.badge}>
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  // import '@a4ui/core/styles.css'
9
9
  // import { Button, Card, Modal } from '@a4ui/core'
10
10
 
11
- export const A4UI_VERSION = '0.15.0'
11
+ export const A4UI_VERSION = '0.17.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -150,10 +150,10 @@ export { MultiStateBadge, type MultiStateBadgeProps, type BadgeState } from './u
150
150
  export { NowPlaying, type NowPlayingProps } from './ui/NowPlaying'
151
151
  export { Expandable, type ExpandableProps } from './ui/Expandable'
152
152
  export { Typewriter, type TypewriterProps } from './ui/Typewriter'
153
- export { Ripple, type RippleProps } from './ui/Ripple'
153
+ export { Ripple, spawnRipple, type RippleProps } from './ui/Ripple'
154
154
  export { Magnetic, type MagneticProps } from './ui/Magnetic'
155
- export { TiltCard, type TiltCardProps } from './ui/TiltCard'
156
- export { Spotlight, type SpotlightProps } from './ui/Spotlight'
155
+ export { TiltCard, attachTilt, type TiltCardProps } from './ui/TiltCard'
156
+ export { Spotlight, attachSpotlight, type SpotlightProps } from './ui/Spotlight'
157
157
  export { ScrollProgress, type ScrollProgressProps } from './ui/ScrollProgress'
158
158
  export { GradientText, type GradientTextProps } from './ui/GradientText'
159
159
  export { flyToCart, type FlyToCartOptions } from './lib/flyToCart'
@@ -56,7 +56,9 @@ export function Accordion(props: AccordionProps): JSX.Element {
56
56
  </svg>
57
57
  </KAccordion.Trigger>
58
58
  </KAccordion.Header>
59
- <KAccordion.Content class="pb-3 text-sm text-muted-foreground">{item.content}</KAccordion.Content>
59
+ <KAccordion.Content class="accordion-content text-sm text-muted-foreground">
60
+ <div class="pb-3">{item.content}</div>
61
+ </KAccordion.Content>
60
62
  </KAccordion.Item>
61
63
  )}
62
64
  </For>
package/src/ui/Badge.tsx CHANGED
@@ -2,7 +2,7 @@
2
2
  // (flowBadgeProps, statusBadgeTone, priorityBadgeTone, …) stay in the consuming
3
3
  // app: they encode business vocabulary (income/expense, project priority, user
4
4
  // roles), not design-system concerns.
5
- import { type JSX, type ParentProps, splitProps } from 'solid-js'
5
+ import { type JSX, type ParentProps, Show, splitProps } from 'solid-js'
6
6
 
7
7
  import { cn } from '../lib/cn'
8
8
 
@@ -23,6 +23,12 @@ const BADGE_BASE =
23
23
  interface BadgeProps extends ParentProps {
24
24
  /** Visual/semantic tone. Defaults to `'neutral'`. */
25
25
  tone?: BadgeTone
26
+ /**
27
+ * Show a pinging dot before the label for "live"/"recording"/"online"
28
+ * states. Tinted with the tone's color (`currentColor`); pure CSS
29
+ * (`animate-ping`), reduced-motion aware.
30
+ */
31
+ pulse?: boolean
26
32
  class?: string
27
33
  }
28
34
 
@@ -37,9 +43,15 @@ interface BadgeProps extends ParentProps {
37
43
  * ```
38
44
  */
39
45
  export function Badge(props: BadgeProps): JSX.Element {
40
- const [local, rest] = splitProps(props, ['tone', 'class', 'children'])
46
+ const [local, rest] = splitProps(props, ['tone', 'class', 'children', 'pulse'])
41
47
  return (
42
48
  <span class={cn(BADGE_BASE, TONE_CLASSES[local.tone ?? 'neutral'], local.class)} {...rest}>
49
+ <Show when={local.pulse}>
50
+ <span class="relative flex h-1.5 w-1.5" aria-hidden="true">
51
+ <span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75" />
52
+ <span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
53
+ </span>
54
+ </Show>
43
55
  {local.children}
44
56
  </span>
45
57
  )
package/src/ui/Button.tsx CHANGED
@@ -2,6 +2,7 @@ import type { JSX, ParentProps } from 'solid-js'
2
2
  import { splitProps } from 'solid-js'
3
3
 
4
4
  import { cn } from '../lib/cn'
5
+ import { spawnRipple } from './Ripple'
5
6
 
6
7
  /** Visual style of a {@link Button}. Defaults to `'primary'`. */
7
8
  export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
@@ -24,6 +25,12 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
24
25
  class?: string
25
26
  /** Defaults to "button" so action buttons inside a form never submit it by accident. */
26
27
  type?: 'button' | 'submit' | 'reset'
28
+ /**
29
+ * Material-style click ripple from the press position. Engine-free (Web
30
+ * Animations API, shared with {@link spawnRipple}) and reduced-motion aware —
31
+ * costs nothing when off.
32
+ */
33
+ ripple?: boolean
27
34
  }
28
35
 
29
36
  /**
@@ -33,14 +40,29 @@ interface ButtonProps extends ParentProps, Omit<JSX.ButtonHTMLAttributes<HTMLBut
33
40
  * @example
34
41
  * ```tsx
35
42
  * <Button variant="outline" onClick={() => save()}>Save</Button>
43
+ * <Button ripple onClick={() => save()}>Save</Button> // with a click ripple
36
44
  * ```
37
45
  */
38
46
  export function Button(props: ButtonProps): JSX.Element {
39
- const [local, rest] = splitProps(props, ['variant', 'class', 'type', 'children'])
47
+ const [local, rest] = splitProps(props, ['variant', 'class', 'type', 'children', 'ripple', 'onPointerDown'])
48
+
49
+ const handlePointerDown: JSX.EventHandler<HTMLButtonElement, PointerEvent> = (event) => {
50
+ if (local.ripple) spawnRipple(event.currentTarget, event, { opacity: 0.35 })
51
+ const user = local.onPointerDown
52
+ if (typeof user === 'function') user(event)
53
+ else if (user) user[0](user[1], event)
54
+ }
55
+
40
56
  return (
41
57
  <button
42
58
  type={local.type ?? 'button'}
43
- class={cn(BUTTON_BASE, VARIANT_CLASSES[local.variant ?? 'primary'], local.class)}
59
+ class={cn(
60
+ BUTTON_BASE,
61
+ VARIANT_CLASSES[local.variant ?? 'primary'],
62
+ local.ripple && 'relative overflow-hidden',
63
+ local.class,
64
+ )}
65
+ onPointerDown={handlePointerDown}
44
66
  {...rest}
45
67
  >
46
68
  {local.children}
package/src/ui/Card.tsx CHANGED
@@ -1,8 +1,10 @@
1
1
  // Card + 4 sub-parts (no CardFooter/CardDescription — add if needed).
2
2
  import type { JSX, ParentProps } from 'solid-js'
3
- import { splitProps } from 'solid-js'
3
+ import { onCleanup, onMount, splitProps } from 'solid-js'
4
4
 
5
5
  import { cn } from '../lib/cn'
6
+ import { attachSpotlight } from './Spotlight'
7
+ import { attachTilt } from './TiltCard'
6
8
 
7
9
  interface DivProps extends ParentProps {
8
10
  class?: string
@@ -16,6 +18,17 @@ interface CardProps extends DivProps {
16
18
  * for glass cards so the look is uniform; pass `glow={false}` to opt out.
17
19
  */
18
20
  glow?: boolean
21
+ /**
22
+ * 3D tilt toward the cursor on hover — the same primitive as `<TiltCard>`,
23
+ * baked in ({@link attachTilt}). Engine-free and reduced-motion aware.
24
+ */
25
+ tilt?: boolean
26
+ /**
27
+ * Soft radial glow following the cursor inside the card — the same primitive
28
+ * as `<Spotlight>`, baked in ({@link attachSpotlight}). Engine-free and
29
+ * reduced-motion aware.
30
+ */
31
+ spotlight?: boolean
19
32
  }
20
33
 
21
34
  /**
@@ -23,9 +36,12 @@ interface CardProps extends DivProps {
23
36
  * "space glass" look. Compose with {@link CardHeader}, {@link CardTitle}, and
24
37
  * {@link CardContent}.
25
38
  *
39
+ * `tilt` and `spotlight` bake in the cursor interactions from the Motion
40
+ * category — no wrapper components needed.
41
+ *
26
42
  * @example
27
43
  * ```tsx
28
- * <Card glass>
44
+ * <Card glass tilt spotlight>
29
45
  * <CardHeader>
30
46
  * <CardTitle>Usage</CardTitle>
31
47
  * </CardHeader>
@@ -34,16 +50,29 @@ interface CardProps extends DivProps {
34
50
  * ```
35
51
  */
36
52
  export function Card(props: CardProps): JSX.Element {
37
- const [local, rest] = splitProps(props, ['class', 'children', 'glass', 'glow'])
53
+ const [local, rest] = splitProps(props, ['class', 'children', 'glass', 'glow', 'tilt', 'spotlight'])
38
54
  // Glow defaults to the card's glass state when not explicitly set.
39
55
  const showGlow = () => (local.glow ?? local.glass) === true
56
+
57
+ let el: HTMLDivElement | undefined
58
+ onMount(() => {
59
+ const cleanups = [
60
+ local.tilt && el ? attachTilt(el, el) : null,
61
+ local.spotlight && el ? attachSpotlight(el) : null,
62
+ ].filter((f): f is () => void => f !== null)
63
+ onCleanup(() => cleanups.forEach((f) => f()))
64
+ })
65
+
40
66
  return (
41
67
  <div
68
+ ref={el}
42
69
  class={cn(
43
70
  local.glass
44
71
  ? 'card rounded-xl text-card-foreground'
45
72
  : 'rounded-xl border border-border bg-card text-card-foreground shadow-sm',
46
73
  showGlow() && 'glow-edge',
74
+ local.spotlight && 'relative overflow-hidden',
75
+ local.tilt && 'will-change-transform',
47
76
  local.class,
48
77
  )}
49
78
  {...rest}
@@ -13,6 +13,12 @@ export interface CarouselProps {
13
13
  slides: JSX.Element[]
14
14
  /** Auto-advance interval in ms. Omit to disable autoplay (also skipped under reduced motion / hover). */
15
15
  autoplayMs?: number
16
+ /**
17
+ * Drag/touch to swipe between slides (in addition to arrows, dots, and arrow
18
+ * keys). The track follows the pointer and snaps on release — pure CSS, no
19
+ * engine. @default true
20
+ */
21
+ swipe?: boolean
16
22
  class?: string
17
23
  }
18
24
 
@@ -52,6 +58,34 @@ export function Carousel(props: CarouselProps): JSX.Element {
52
58
  }
53
59
  }
54
60
 
61
+ // --- drag/touch swipe (engine-free; the track follows the pointer, then the
62
+ // CSS transition snaps to the resolved slide on release) -------------------
63
+ const swipeOn = (): boolean => props.swipe !== false
64
+ let viewport: HTMLDivElement | undefined
65
+ const [dragging, setDragging] = createSignal(false)
66
+ const [dragPx, setDragPx] = createSignal(0)
67
+ let startX = 0
68
+
69
+ const onPointerDown = (e: PointerEvent): void => {
70
+ if (!swipeOn() || count() < 2) return
71
+ startX = e.clientX
72
+ setDragging(true)
73
+ ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
74
+ }
75
+ const onPointerMove = (e: PointerEvent): void => {
76
+ if (dragging()) setDragPx(e.clientX - startX)
77
+ }
78
+ const endDrag = (): void => {
79
+ if (!dragging()) return
80
+ const width = viewport?.clientWidth ?? 1
81
+ const dx = dragPx()
82
+ const threshold = Math.max(48, width * 0.2)
83
+ if (dx <= -threshold) next()
84
+ else if (dx >= threshold) prev()
85
+ setDragPx(0)
86
+ setDragging(false)
87
+ }
88
+
55
89
  // --- autoplay (paused on hover, skipped under reduced motion) --------------
56
90
  const [paused, setPaused] = createSignal(false)
57
91
  onMount(() => {
@@ -75,10 +109,21 @@ export function Carousel(props: CarouselProps): JSX.Element {
75
109
  onPointerEnter={() => setPaused(true)}
76
110
  onPointerLeave={() => setPaused(false)}
77
111
  >
78
- <div class="relative overflow-hidden rounded-xl border border-border bg-card text-foreground">
112
+ <div
113
+ ref={viewport}
114
+ class="relative overflow-hidden rounded-xl border border-border bg-card text-foreground"
115
+ >
79
116
  <div
80
- class="flex transition-transform duration-500 ease-out"
81
- style={{ transform: `translateX(-${index() * 100}%)` }}
117
+ class={cn(
118
+ 'flex',
119
+ swipeOn() && count() > 1 && 'touch-pan-y cursor-grab active:cursor-grabbing',
120
+ !dragging() && 'transition-transform duration-500 ease-out',
121
+ )}
122
+ style={{ transform: `translateX(calc(-${index() * 100}% + ${dragPx()}px))` }}
123
+ onPointerDown={onPointerDown}
124
+ onPointerMove={onPointerMove}
125
+ onPointerUp={endDrag}
126
+ onPointerCancel={endDrag}
82
127
  >
83
128
  <For each={props.slides}>{(slide) => <div class="w-full shrink-0">{slide}</div>}</For>
84
129
  </div>
@@ -1,7 +1,6 @@
1
1
  // Single controlled collapsible region with a toggle header.
2
2
  import { ChevronDown } from 'lucide-solid'
3
3
  import type { JSX, ParentProps } from 'solid-js'
4
- import { Show } from 'solid-js'
5
4
  import { cn } from '../lib/cn'
6
5
 
7
6
  interface CollapseProps extends ParentProps {
@@ -40,9 +39,19 @@ export function Collapse(props: CollapseProps): JSX.Element {
40
39
  class={cn('h-4 w-4 shrink-0 transition-transform duration-200', props.open && 'rotate-180')}
41
40
  />
42
41
  </button>
43
- <Show when={props.open}>
44
- <div class="px-3 py-2 text-sm text-muted-foreground">{props.children}</div>
45
- </Show>
42
+ {/* grid-rows 0fr->1fr animates the panel height with pure CSS (no measuring,
43
+ no engine); the inner div is `overflow-hidden` so the collapsed content
44
+ is clipped, not just transparent. */}
45
+ <div
46
+ class={cn(
47
+ 'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
48
+ props.open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
49
+ )}
50
+ >
51
+ <div class="overflow-hidden">
52
+ <div class="px-3 py-2 text-sm text-muted-foreground">{props.children}</div>
53
+ </div>
54
+ </div>
46
55
  </div>
47
56
  )
48
57
  }