@a4ui/core 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ // LineChart — a multi-series SVG line chart (no charting library). Every
2
+ // series is scaled against the global min/max across *all* series so they
3
+ // stay comparable, then drawn as one polyline each, with an optional
4
+ // low-opacity area fill down to the baseline. Colors come from theme tokens
5
+ // only, so the chart recolors with the active palette automatically.
6
+ import { createSignal, For, Show, type JSX } from 'solid-js'
7
+
8
+ import { cn } from '../lib/cn'
9
+
10
+ export interface LineSeries {
11
+ name?: string
12
+ tone?: 'primary' | 'accent' | 'muted' | 'foreground'
13
+ data: number[]
14
+ area?: boolean
15
+ }
16
+
17
+ export interface LineChartProps {
18
+ series: LineSeries[]
19
+ labels?: string[]
20
+ /** Height of the plot area in px. Default 200. */
21
+ height?: number
22
+ /** Render a small circle at each data point. Default false. */
23
+ showDots?: boolean
24
+ /** Show a crosshair + value tooltip on pointer hover. Default false. */
25
+ showTooltip?: boolean
26
+ class?: string
27
+ }
28
+
29
+ /** Logical SVG width; the element itself scales to its container via viewBox. */
30
+ const VIEW_W = 400
31
+ const PAD_Y = 4
32
+ const TONE_CYCLE: NonNullable<LineSeries['tone']>[] = ['primary', 'accent', 'muted']
33
+
34
+ function toneFor(series: LineSeries, index: number): NonNullable<LineSeries['tone']> {
35
+ return series.tone ?? TONE_CYCLE[index % TONE_CYCLE.length]
36
+ }
37
+
38
+ function toneColor(tone: NonNullable<LineSeries['tone']>): string {
39
+ return tone === 'foreground' ? 'hsl(var(--foreground))' : `hsl(var(--${tone}))`
40
+ }
41
+
42
+ function toneColorAlpha(tone: NonNullable<LineSeries['tone']>, alpha: number): string {
43
+ const varName = tone === 'foreground' ? '--foreground' : `--${tone}`
44
+ return `hsl(var(${varName}) / ${alpha})`
45
+ }
46
+
47
+ /** Point count used for the x-axis: the longest `data` array across all series. */
48
+ function pointCount(series: LineSeries[]): number {
49
+ return Math.max(0, ...series.map((s) => s.data.length))
50
+ }
51
+
52
+ /** Global min/max across every series' values, so all series share one y-scale. */
53
+ function globalRange(series: LineSeries[]): { min: number; max: number } {
54
+ const values = series.flatMap((s) => s.data)
55
+ if (values.length === 0) return { min: 0, max: 1 }
56
+ const min = Math.min(...values)
57
+ const max = Math.max(...values)
58
+ return min === max ? { min: min - 1, max: max + 1 } : { min, max }
59
+ }
60
+
61
+ function yFor(value: number, min: number, max: number, h: number): number {
62
+ const span = max - min || 1
63
+ const innerH = Math.max(h - PAD_Y * 2, 0)
64
+ return PAD_Y + innerH - ((value - min) / span) * innerH
65
+ }
66
+
67
+ function xFor(index: number, count: number): number {
68
+ if (count <= 1) return VIEW_W / 2
69
+ return index * (VIEW_W / (count - 1))
70
+ }
71
+
72
+ function pointsFor(
73
+ data: number[],
74
+ count: number,
75
+ min: number,
76
+ max: number,
77
+ h: number,
78
+ ): { x: number; y: number }[] {
79
+ return data.map((value, i) => ({ x: xFor(i, count), y: yFor(value, min, max, h) }))
80
+ }
81
+
82
+ /**
83
+ * Multi-series SVG line chart scaled to fit its container (responsive
84
+ * viewBox, `preserveAspectRatio="none"`), with a shared y-scale across all
85
+ * series. Series with `area: true` also get a faint fill to the baseline.
86
+ * Assumes equal-length `data` arrays across series; if lengths differ, the
87
+ * x-axis is built from the longest one.
88
+ *
89
+ * @example
90
+ * ```tsx
91
+ * <LineChart
92
+ * series={[
93
+ * { name: 'Revenue', tone: 'primary', data: [4, 6, 5, 9, 8, 12], area: true },
94
+ * { name: 'Costs', tone: 'muted', data: [2, 3, 3, 4, 5, 6] },
95
+ * ]}
96
+ * labels={['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']}
97
+ * showDots
98
+ * showTooltip
99
+ * />
100
+ * ```
101
+ */
102
+ export function LineChart(props: LineChartProps): JSX.Element {
103
+ const height = () => props.height ?? 200
104
+ const count = () => pointCount(props.series)
105
+ const range = () => globalRange(props.series)
106
+ const namedSeries = () => props.series.filter((s) => s.name)
107
+
108
+ const seriesPoints = () =>
109
+ props.series.map((s) => pointsFor(s.data, count(), range().min, range().max, height()))
110
+
111
+ const areaPath = (points: { x: number; y: number }[]): string => {
112
+ if (points.length === 0) return ''
113
+ const line = points.map((p) => `${p.x},${p.y}`).join(' L ')
114
+ const first = points[0]
115
+ const last = points[points.length - 1]
116
+ return `M ${first.x},${height()} L ${line} L ${last.x},${height()} Z`
117
+ }
118
+
119
+ let svgRef: SVGSVGElement | undefined
120
+ const [hoverIndex, setHoverIndex] = createSignal<number | null>(null)
121
+
122
+ const handlePointerMove = (event: PointerEvent) => {
123
+ if (!svgRef || count() === 0) return
124
+ const rect = svgRef.getBoundingClientRect()
125
+ const fraction = rect.width === 0 ? 0 : (event.clientX - rect.left) / rect.width
126
+ const clamped = Math.min(1, Math.max(0, fraction))
127
+ const idx = count() <= 1 ? 0 : Math.round(clamped * (count() - 1))
128
+ setHoverIndex(idx)
129
+ }
130
+
131
+ const handlePointerLeave = () => setHoverIndex(null)
132
+
133
+ const ariaLabel = () => {
134
+ const names = namedSeries()
135
+ .map((s) => s.name)
136
+ .join(', ')
137
+ const seriesLabel = names || `${props.series.length} series`
138
+ return `Line chart with ${seriesLabel}, ${count()} data points each`
139
+ }
140
+
141
+ return (
142
+ <div class={cn('w-full', props.class)}>
143
+ <Show when={namedSeries().length >= 2}>
144
+ <div class="mb-2 flex flex-wrap gap-x-3 gap-y-1">
145
+ <For each={props.series}>
146
+ {(s, i) => (
147
+ <Show when={s.name}>
148
+ <span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
149
+ <span
150
+ class="inline-block size-2 rounded-full"
151
+ style={{ 'background-color': toneColor(toneFor(s, i())) }}
152
+ />
153
+ {s.name}
154
+ </span>
155
+ </Show>
156
+ )}
157
+ </For>
158
+ </div>
159
+ </Show>
160
+
161
+ <div class="relative w-full">
162
+ <svg
163
+ ref={svgRef}
164
+ class="block w-full overflow-visible"
165
+ viewBox={`0 0 ${VIEW_W} ${height()}`}
166
+ preserveAspectRatio="none"
167
+ height={height()}
168
+ role="img"
169
+ aria-label={ariaLabel()}
170
+ onPointerMove={props.showTooltip ? handlePointerMove : undefined}
171
+ onPointerLeave={props.showTooltip ? handlePointerLeave : undefined}
172
+ >
173
+ <For each={props.series}>
174
+ {(s, i) => {
175
+ const points = () => seriesPoints()[i()]
176
+ const tone = () => toneFor(s, i())
177
+ const polylinePoints = () =>
178
+ points()
179
+ .map((p) => `${p.x},${p.y}`)
180
+ .join(' ')
181
+ return (
182
+ <>
183
+ <Show when={s.area}>
184
+ <path d={areaPath(points())} fill={toneColorAlpha(tone(), 0.15)} stroke="none" />
185
+ </Show>
186
+ <polyline
187
+ points={polylinePoints()}
188
+ fill="none"
189
+ stroke={toneColor(tone())}
190
+ stroke-width={2}
191
+ stroke-linecap="round"
192
+ stroke-linejoin="round"
193
+ vector-effect="non-scaling-stroke"
194
+ />
195
+ <Show when={props.showDots}>
196
+ <For each={points()}>
197
+ {(p) => (
198
+ <circle
199
+ cx={p.x}
200
+ cy={p.y}
201
+ r={2.5}
202
+ fill={toneColor(tone())}
203
+ vector-effect="non-scaling-stroke"
204
+ />
205
+ )}
206
+ </For>
207
+ </Show>
208
+ </>
209
+ )
210
+ }}
211
+ </For>
212
+
213
+ <Show when={props.showTooltip && hoverIndex() !== null}>
214
+ <line
215
+ x1={xFor(hoverIndex() ?? 0, count())}
216
+ x2={xFor(hoverIndex() ?? 0, count())}
217
+ y1={0}
218
+ y2={height()}
219
+ stroke="hsl(var(--border))"
220
+ stroke-width={1}
221
+ vector-effect="non-scaling-stroke"
222
+ />
223
+ </Show>
224
+ </svg>
225
+
226
+ <Show when={props.showTooltip && hoverIndex() !== null}>
227
+ <div
228
+ class="pointer-events-none absolute top-0 z-10 -translate-y-1 rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md"
229
+ style={{
230
+ left: `${((hoverIndex() ?? 0) / Math.max(count() - 1, 1)) * 100}%`,
231
+ transform: `translateX(${
232
+ (hoverIndex() ?? 0) / Math.max(count() - 1, 1) > 0.8 ? '-100%' : '-8px'
233
+ }) translateY(-100%)`,
234
+ }}
235
+ >
236
+ <Show when={props.labels?.[hoverIndex() ?? -1]}>
237
+ <div class="mb-0.5 font-medium text-foreground">{props.labels?.[hoverIndex() ?? -1]}</div>
238
+ </Show>
239
+ <For each={props.series}>
240
+ {(s, i) => (
241
+ <Show when={s.data[hoverIndex() ?? -1] !== undefined}>
242
+ <div class="flex items-center gap-1.5">
243
+ <span
244
+ class="inline-block size-1.5 rounded-full"
245
+ style={{ 'background-color': toneColor(toneFor(s, i())) }}
246
+ />
247
+ <span>{s.name ?? `Series ${i() + 1}`}:</span>
248
+ <span class="font-medium text-foreground">{s.data[hoverIndex() ?? -1]}</span>
249
+ </div>
250
+ </Show>
251
+ )}
252
+ </For>
253
+ </div>
254
+ </Show>
255
+ </div>
256
+
257
+ <Show when={props.labels && props.labels.length > 0}>
258
+ <div class="mt-1 flex w-full text-xs text-muted-foreground">
259
+ <For each={props.labels}>{(label) => <span class="flex-1 truncate text-center">{label}</span>}</For>
260
+ </div>
261
+ </Show>
262
+ </div>
263
+ )
264
+ }
@@ -0,0 +1,215 @@
1
+ // RadarChart — a native SVG spider/radar chart (no charting library). Axes
2
+ // are laid out clockwise starting at 12 o'clock, one spoke per dimension.
3
+ // A faint concentric polygon grid marks reference levels, and each series is
4
+ // drawn as a translucent-filled polygon connecting its normalized values.
5
+ // Colors come from theme tokens, either per-series `tone` or, when omitted,
6
+ // cycled across `--primary` / `--accent` by index.
7
+ import { For, Show, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+
11
+ export interface RadarSeries {
12
+ name?: string
13
+ tone?: 'primary' | 'accent'
14
+ values: number[]
15
+ }
16
+
17
+ export interface RadarChartProps {
18
+ axes: string[]
19
+ series: RadarSeries[]
20
+ /** Value that reaches the outer edge of the grid. Default: max across all series. */
21
+ max?: number
22
+ /** Overall SVG width/height in px (square). Default 240. */
23
+ size?: number
24
+ class?: string
25
+ }
26
+
27
+ // Explicit tone -> CSS custom property name.
28
+ const TONE_TOKENS: Record<'primary' | 'accent', string> = {
29
+ primary: '--primary',
30
+ accent: '--accent',
31
+ }
32
+
33
+ // Fallback rotation used when a series has no explicit `tone`.
34
+ const CYCLE_TOKENS = ['--primary', '--accent'] as const
35
+
36
+ /** Resolve the CSS custom property a series should stroke/fill with. */
37
+ function tokenFor(series: RadarSeries, index: number): string {
38
+ if (series.tone) return TONE_TOKENS[series.tone]
39
+ return CYCLE_TOKENS[index % CYCLE_TOKENS.length]
40
+ }
41
+
42
+ const RING_LEVELS = 4
43
+ const LABEL_OFFSET = 14
44
+ // Extra viewBox margin (all sides) so axis-label text isn't clipped.
45
+ const LABEL_PAD = 34
46
+
47
+ /** Angle (in degrees) of axis `index` of `count`, starting at 12 o'clock and running clockwise. */
48
+ function axisAngle(index: number, count: number): number {
49
+ return -90 + (360 / count) * index
50
+ }
51
+
52
+ /** Convert a polar coordinate (center + radius + angle in degrees) to cartesian `{ x, y }`. */
53
+ function polarToPoint(cx: number, cy: number, r: number, angleDeg: number): { x: number; y: number } {
54
+ const rad = (angleDeg * Math.PI) / 180
55
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
56
+ }
57
+
58
+ /** Build the `x,y ...` points attribute for a polygon at `radius` across all axes. */
59
+ function ringPoints(cx: number, cy: number, radius: number, axisCount: number): string {
60
+ return Array.from({ length: axisCount }, (_, i) => {
61
+ const p = polarToPoint(cx, cy, radius, axisAngle(i, axisCount))
62
+ return `${p.x},${p.y}`
63
+ }).join(' ')
64
+ }
65
+
66
+ /** Text anchor for a label sitting outside the vertex at `angleDeg`. */
67
+ function labelAnchor(angleDeg: number): 'start' | 'middle' | 'end' {
68
+ const cos = Math.cos((angleDeg * Math.PI) / 180)
69
+ if (cos > 0.1) return 'start'
70
+ if (cos < -0.1) return 'end'
71
+ return 'middle'
72
+ }
73
+
74
+ /** Dominant baseline for a label sitting outside the vertex at `angleDeg`. */
75
+ function labelBaseline(angleDeg: number): 'auto' | 'hanging' | 'middle' {
76
+ const sin = Math.sin((angleDeg * Math.PI) / 180)
77
+ if (sin < -0.3) return 'auto'
78
+ if (sin > 0.3) return 'hanging'
79
+ return 'middle'
80
+ }
81
+
82
+ /**
83
+ * SVG spider/radar chart: a concentric polygon grid with one spoke per axis,
84
+ * and one translucent-filled polygon per series connecting its values
85
+ * (normalized by `max`, default the max value across all series). Series
86
+ * colors come from each datum's `tone`, or cycle through `primary`/`accent`
87
+ * by index when omitted. Shows a small legend when 2+ series have a `name`.
88
+ *
89
+ * @example
90
+ * ```tsx
91
+ * <RadarChart
92
+ * axes={['Speed', 'Power', 'Range', 'Defense', 'Utility']}
93
+ * series={[
94
+ * { name: 'Model A', tone: 'primary', values: [80, 60, 90, 40, 70] },
95
+ * { name: 'Model B', tone: 'accent', values: [50, 85, 60, 75, 55] },
96
+ * ]}
97
+ * />
98
+ * ```
99
+ */
100
+ export function RadarChart(props: RadarChartProps): JSX.Element {
101
+ const size = () => props.size ?? 240
102
+ const center = () => size() / 2
103
+ const outerRadius = () => Math.max(size() / 2 - LABEL_OFFSET * 2, 0)
104
+ const axisCount = () => props.axes.length
105
+ const max = () => props.max ?? Math.max(1, ...props.series.flatMap((s) => s.values))
106
+
107
+ const rings = () =>
108
+ Array.from({ length: RING_LEVELS }, (_, i) => {
109
+ const level = i + 1
110
+ return ringPoints(center(), center(), outerRadius() * (level / RING_LEVELS), axisCount())
111
+ })
112
+
113
+ const spokes = () =>
114
+ props.axes.map((_, i) => polarToPoint(center(), center(), outerRadius(), axisAngle(i, axisCount())))
115
+
116
+ const labels = () =>
117
+ props.axes.map((label, i) => {
118
+ const angle = axisAngle(i, axisCount())
119
+ const point = polarToPoint(center(), center(), outerRadius() + LABEL_OFFSET, angle)
120
+ return { label, x: point.x, y: point.y, anchor: labelAnchor(angle), baseline: labelBaseline(angle) }
121
+ })
122
+
123
+ const seriesPolygons = () => {
124
+ const grandMax = max()
125
+ return props.series.map((series, index) => {
126
+ const points = series.values
127
+ .map((value, i) => {
128
+ const fraction = grandMax > 0 ? Math.max(value, 0) / grandMax : 0
129
+ const p = polarToPoint(center(), center(), outerRadius() * fraction, axisAngle(i, axisCount()))
130
+ return `${p.x},${p.y}`
131
+ })
132
+ .join(' ')
133
+ return { series, index, points, token: tokenFor(series, index) }
134
+ })
135
+ }
136
+
137
+ const namedSeries = () => props.series.filter((s) => s.name)
138
+ const showLegend = () => namedSeries().length >= 2
139
+
140
+ return (
141
+ <div class={cn('inline-flex flex-col items-center gap-2', props.class)}>
142
+ <svg
143
+ width={size()}
144
+ height={size()}
145
+ // Pad the coordinate space so axis-label text near the left/right
146
+ // vertices isn't clipped by the viewBox edge (the plot scales down a
147
+ // touch to fit; the CSS footprint stays `size`).
148
+ viewBox={`${-LABEL_PAD} ${-LABEL_PAD} ${size() + LABEL_PAD * 2} ${size() + LABEL_PAD * 2}`}
149
+ role="img"
150
+ aria-label={`Radar chart comparing ${props.series.length} series across ${axisCount()} axes: ${props.axes.join(', ')}`}
151
+ >
152
+ <For each={rings()}>
153
+ {(points) => <polygon points={points} fill="none" stroke="hsl(var(--border))" stroke-width={1} />}
154
+ </For>
155
+ <For each={spokes()}>
156
+ {(point) => (
157
+ <line
158
+ x1={center()}
159
+ y1={center()}
160
+ x2={point.x}
161
+ y2={point.y}
162
+ stroke="hsl(var(--border))"
163
+ stroke-width={1}
164
+ />
165
+ )}
166
+ </For>
167
+ <For each={seriesPolygons()}>
168
+ {({ series, points, token }) => (
169
+ <polygon
170
+ points={points}
171
+ fill={`hsl(var(${token}) / 0.15)`}
172
+ stroke={`hsl(var(${token}))`}
173
+ stroke-width={2}
174
+ stroke-linejoin="round"
175
+ >
176
+ <Show when={series.name}>
177
+ <title>{series.name}</title>
178
+ </Show>
179
+ </polygon>
180
+ )}
181
+ </For>
182
+ <For each={labels()}>
183
+ {(item) => (
184
+ <text
185
+ x={item.x}
186
+ y={item.y}
187
+ text-anchor={item.anchor}
188
+ dominant-baseline={item.baseline}
189
+ class="text-xs fill-muted-foreground"
190
+ >
191
+ {item.label}
192
+ </text>
193
+ )}
194
+ </For>
195
+ </svg>
196
+ <Show when={showLegend()}>
197
+ <div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
198
+ <For each={seriesPolygons()}>
199
+ {({ series, token }) => (
200
+ <Show when={series.name}>
201
+ <span class="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
202
+ <span
203
+ class="inline-block size-2 rounded-full"
204
+ style={{ 'background-color': `hsl(var(${token}))` }}
205
+ />
206
+ {series.name}
207
+ </span>
208
+ </Show>
209
+ )}
210
+ </For>
211
+ </div>
212
+ </Show>
213
+ </div>
214
+ )
215
+ }
@@ -3,3 +3,6 @@
3
3
  export { Sparkline, type SparklineProps } from './Sparkline'
4
4
  export { BarChart, type BarDatum, type BarChartProps } from './BarChart'
5
5
  export { DonutChart, type DonutSegment, type DonutChartProps } from './DonutChart'
6
+ export { LineChart, type LineSeries, type LineChartProps } from './LineChart'
7
+ export { GaugeChart, type GaugeThreshold, type GaugeChartProps } from './GaugeChart'
8
+ export { RadarChart, type RadarSeries, type RadarChartProps } from './RadarChart'
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.31.0'
11
+ export const A4UI_VERSION = '0.32.0'
12
12
 
13
13
  // Helpers (src/lib) — generic, framework-level utilities.
14
14
  export { cn } from './lib/cn'
@@ -179,6 +179,13 @@ export { FocusBlurGroup, type FocusBlurGroupProps } from './ui/FocusBlurGroup'
179
179
  export { CardSpread, type CardSpreadProps, type CardSpreadLayout } from './ui/CardSpread'
180
180
  export { Carousel3D, type Carousel3DProps, type Carousel3DVariant } from './ui/Carousel3D'
181
181
  export { TimeMachineStack, type TimeMachineStackProps } from './ui/TimeMachineStack'
182
+
183
+ // Spatial "wow" primitives (bento, dock, beams, meteors).
184
+ export { BentoGrid, BentoCard, type BentoGridProps, type BentoCardProps } from './ui/BentoGrid'
185
+ export { Dock, type DockItem, type DockProps } from './ui/Dock'
186
+ export { AnimatedBeam, type AnimatedBeamProps } from './ui/AnimatedBeam'
187
+ export { BorderBeam, type BorderBeamProps } from './ui/BorderBeam'
188
+ export { Meteors, type MeteorsProps } from './ui/Meteors'
182
189
  export {
183
190
  CopyButton,
184
191
  type CopyButtonProps,
@@ -0,0 +1,187 @@
1
+ // AnimatedBeam — an absolutely-positioned SVG overlay that draws a curved
2
+ // line between two elements (`fromRef` → `toRef`) inside a positioned
3
+ // `containerRef`, with a faint static stroke under a traveling
4
+ // primary→accent gradient segment that loops along the path. Path geometry
5
+ // is recomputed on mount, on window resize, and via a ResizeObserver on the
6
+ // container. No travel (static stroke only) under reduced motion.
7
+ import { createEffect, createSignal, createUniqueId, onCleanup, onMount, type JSX } from 'solid-js'
8
+
9
+ import { cn } from '../lib/cn'
10
+ import { animate, motionReduced } from '../lib/motion'
11
+
12
+ export interface AnimatedBeamProps {
13
+ containerRef: HTMLElement | undefined
14
+ fromRef: HTMLElement | undefined
15
+ toRef: HTMLElement | undefined
16
+ /** Px the curve bows away from a straight line. @default 0 (straight) */
17
+ curvature?: number
18
+ /** Travel from `toRef` to `fromRef` instead of the default direction. */
19
+ reverse?: boolean
20
+ /** Seconds for one travel of the gradient segment. @default 3 */
21
+ duration?: number
22
+ class?: string
23
+ }
24
+
25
+ interface Point {
26
+ x: number
27
+ y: number
28
+ }
29
+
30
+ /**
31
+ * Renders an animated SVG "beam" connecting two elements: a faint static
32
+ * base stroke under a traveling gradient segment that loops from `fromRef`
33
+ * to `toRef` (or the reverse, with `reverse`). Absolutely positioned over
34
+ * `containerRef`, whose bounds establish the coordinate space both
35
+ * endpoints are measured against. Static (no travel) under reduced motion.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * let container: HTMLDivElement | undefined
40
+ * let fromEl: HTMLDivElement | undefined
41
+ * let toEl: HTMLDivElement | undefined
42
+ *
43
+ * <div ref={container} class="relative flex items-center justify-between p-8">
44
+ * <div ref={fromEl} class="size-10 rounded-full bg-card" />
45
+ * <div ref={toEl} class="size-10 rounded-full bg-card" />
46
+ * <AnimatedBeam containerRef={container} fromRef={fromEl} toRef={toEl} curvature={40} />
47
+ * </div>
48
+ * ```
49
+ */
50
+ export function AnimatedBeam(props: AnimatedBeamProps): JSX.Element {
51
+ const [pathD, setPathD] = createSignal('')
52
+ const [size, setSize] = createSignal({ w: 0, h: 0 })
53
+ const gradientId = `beam-gradient-${createUniqueId()}`
54
+
55
+ let gradientEl: SVGLinearGradientElement | undefined
56
+ let start: Point = { x: 0, y: 0 }
57
+ let end: Point = { x: 0, y: 0 }
58
+ let control: Point = { x: 0, y: 0 }
59
+
60
+ const recompute = (): void => {
61
+ const container = props.containerRef
62
+ const from = props.fromRef
63
+ const to = props.toRef
64
+ if (!container || !from || !to) return
65
+
66
+ const containerRect = container.getBoundingClientRect()
67
+ const fromRect = from.getBoundingClientRect()
68
+ const toRect = to.getBoundingClientRect()
69
+
70
+ start = {
71
+ x: fromRect.left - containerRect.left + fromRect.width / 2,
72
+ y: fromRect.top - containerRect.top + fromRect.height / 2,
73
+ }
74
+ end = {
75
+ x: toRect.left - containerRect.left + toRect.width / 2,
76
+ y: toRect.top - containerRect.top + toRect.height / 2,
77
+ }
78
+
79
+ const curvature = props.curvature ?? 0
80
+ const mx = (start.x + end.x) / 2
81
+ const my = (start.y + end.y) / 2
82
+ const dx = end.x - start.x
83
+ const dy = end.y - start.y
84
+ const length = Math.hypot(dx, dy) || 1
85
+ control = {
86
+ x: mx + (-dy / length) * curvature,
87
+ y: my + (dx / length) * curvature,
88
+ }
89
+
90
+ setSize({ w: containerRect.width, h: containerRect.height })
91
+ setPathD(`M ${start.x} ${start.y} Q ${control.x} ${control.y} ${end.x} ${end.y}`)
92
+ }
93
+
94
+ /** Point at parameter `t` (0..1) along the quadratic curve. */
95
+ const pointAt = (t: number): Point => {
96
+ const it = 1 - t
97
+ return {
98
+ x: it * it * start.x + 2 * it * t * control.x + t * t * end.x,
99
+ y: it * it * start.y + 2 * it * t * control.y + t * t * end.y,
100
+ }
101
+ }
102
+
103
+ createEffect(() => {
104
+ // Track the refs reactively — recompute picks up the initial mount and
105
+ // any later change (e.g. a ref that resolves after this beam mounts).
106
+ void props.containerRef
107
+ void props.fromRef
108
+ void props.toRef
109
+ recompute()
110
+ })
111
+
112
+ onMount(() => {
113
+ const handleResize = (): void => recompute()
114
+ window.addEventListener('resize', handleResize)
115
+
116
+ let resizeObserver: ResizeObserver | undefined
117
+ if (props.containerRef && typeof ResizeObserver !== 'undefined') {
118
+ resizeObserver = new ResizeObserver(() => recompute())
119
+ resizeObserver.observe(props.containerRef)
120
+ }
121
+
122
+ let controls: ReturnType<typeof animate> | undefined
123
+ if (!motionReduced()) {
124
+ const segment = 0.2
125
+ controls = animate(0, 1, {
126
+ duration: props.duration ?? 3,
127
+ repeat: Infinity,
128
+ ease: 'linear',
129
+ onUpdate: (p: number) => {
130
+ const t = props.reverse ? 1 - p : p
131
+ const lead = props.reverse ? Math.min(1, t + segment) : t
132
+ const trail = props.reverse ? t : Math.max(0, t - segment)
133
+ const from = pointAt(trail)
134
+ const to = pointAt(lead)
135
+ gradientEl?.setAttribute('x1', String(from.x))
136
+ gradientEl?.setAttribute('y1', String(from.y))
137
+ gradientEl?.setAttribute('x2', String(to.x))
138
+ gradientEl?.setAttribute('y2', String(to.y))
139
+ },
140
+ })
141
+ }
142
+
143
+ onCleanup(() => {
144
+ window.removeEventListener('resize', handleResize)
145
+ resizeObserver?.disconnect()
146
+ controls?.stop()
147
+ })
148
+ })
149
+
150
+ return (
151
+ <svg
152
+ aria-hidden="true"
153
+ class={cn('pointer-events-none absolute inset-0', props.class)}
154
+ width={size().w}
155
+ height={size().h}
156
+ viewBox={`0 0 ${size().w} ${size().h}`}
157
+ fill="none"
158
+ >
159
+ <path d={pathD()} stroke="hsl(var(--border))" stroke-width="2" stroke-opacity="0.4" fill="none" />
160
+ {!motionReduced() && (
161
+ <>
162
+ <defs>
163
+ <linearGradient
164
+ ref={(el) => {
165
+ gradientEl = el
166
+ }}
167
+ id={gradientId}
168
+ gradientUnits="userSpaceOnUse"
169
+ >
170
+ <stop offset="0%" stop-color="hsl(var(--primary))" stop-opacity="0" />
171
+ <stop offset="50%" stop-color="hsl(var(--primary))" />
172
+ <stop offset="50%" stop-color="hsl(var(--accent))" />
173
+ <stop offset="100%" stop-color="hsl(var(--accent))" stop-opacity="0" />
174
+ </linearGradient>
175
+ </defs>
176
+ <path
177
+ d={pathD()}
178
+ stroke={`url(#${gradientId})`}
179
+ stroke-width="2"
180
+ stroke-linecap="round"
181
+ fill="none"
182
+ />
183
+ </>
184
+ )}
185
+ </svg>
186
+ )
187
+ }