chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import { type CSSProperties, type ReactNode, useEffect, useId, useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import styles from '@/components/ui/thinking-loader.module.css'
|
||||
|
||||
const VARIANTS = [
|
||||
'metaballs',
|
||||
'relay',
|
||||
'corners',
|
||||
'burst',
|
||||
'compass',
|
||||
'squeeze',
|
||||
'thinking',
|
||||
'orb',
|
||||
] as const
|
||||
|
||||
export type ThinkingLoaderVariant = (typeof VARIANTS)[number]
|
||||
|
||||
/**
|
||||
* Shapes used in the random morph cycle. `orb` (the solid terminal circle) is
|
||||
* excluded — it is only reachable by pinning `variant='orb'` or via `settle`,
|
||||
* so the cycle never lands on it mid-stream.
|
||||
*/
|
||||
const CYCLE_VARIANTS = VARIANTS.filter((v) => v !== 'orb')
|
||||
|
||||
/**
|
||||
* Deterministic integer hash — turns a step index into a spread-out pseudo-
|
||||
* random number. Used to pick both the shape and its hold time per step, so the
|
||||
* order and pacing look unpredictable (any shape can follow any other, some
|
||||
* linger, some pass quickly) while staying a pure function of the clock — every
|
||||
* loader instance lands on the same shape at the same time.
|
||||
*/
|
||||
function hashStep(n: number): number {
|
||||
let x = n | 0
|
||||
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
|
||||
x = Math.imul((x >>> 16) ^ x, 0x45d9f3b)
|
||||
return ((x >>> 16) ^ x) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Common multiple of every shape animation period (800/1000/1200/2000ms,
|
||||
* alternates doubled) — the wall-clock modulus for the shared negative
|
||||
* animation-delay that phase-locks instances mounted at different times.
|
||||
*/
|
||||
const SYNC_PERIOD_MS = 12_000
|
||||
|
||||
/**
|
||||
* A super-cycle of steps, each holding a pseudo-random shape for a pseudo-random
|
||||
* duration (≈1.1–2.6s), so the morph pacing feels natural — some shapes linger,
|
||||
* some pass quickly — instead of a metronome. Deterministic, so instances stay
|
||||
* in lockstep; long enough not to read as a loop.
|
||||
*/
|
||||
const CYCLE_STEPS = 16
|
||||
const STEP_MIN_MS = 1100
|
||||
const STEP_RANGE_MS = 1500
|
||||
const STEP_DURATIONS = Array.from(
|
||||
{ length: CYCLE_STEPS },
|
||||
(_, i) => STEP_MIN_MS + (hashStep(i + 1000) % STEP_RANGE_MS)
|
||||
)
|
||||
const SUPER_PERIOD_MS = STEP_DURATIONS.reduce((sum, d) => sum + d, 0)
|
||||
|
||||
/** The shape for a given step — pseudo-random, never an immediate repeat. */
|
||||
function variantForStep(i: number): ThinkingLoaderVariant {
|
||||
const idx = hashStep(i) % CYCLE_VARIANTS.length
|
||||
const prevIdx = hashStep((i - 1 + CYCLE_STEPS) % CYCLE_STEPS) % CYCLE_VARIANTS.length
|
||||
return CYCLE_VARIANTS[idx === prevIdx ? (idx + 1) % CYCLE_VARIANTS.length : idx]
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape the shared timeline is on right now, and how long until the next.
|
||||
* Walks the super-cycle's varied step durations — a pure function of the wall
|
||||
* clock, so instances stay in lockstep.
|
||||
*/
|
||||
function variantAtNow(): { variant: ThinkingLoaderVariant; msUntilNext: number } {
|
||||
let t = Date.now() % SUPER_PERIOD_MS
|
||||
for (let i = 0; i < CYCLE_STEPS; i++) {
|
||||
if (t < STEP_DURATIONS[i]) {
|
||||
return { variant: variantForStep(i), msUntilNext: STEP_DURATIONS[i] - t }
|
||||
}
|
||||
t -= STEP_DURATIONS[i]
|
||||
}
|
||||
return { variant: variantForStep(0), msUntilNext: STEP_DURATIONS[0] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ink shapes per variant, authored in the shared 100x100 viewBox.
|
||||
* Geometry mirrors the intrinsic CSS loaders these were adapted from,
|
||||
* contain-fit to the canvas. Animations live in the CSS module.
|
||||
*/
|
||||
const VARIANT_SHAPES: Record<ThinkingLoaderVariant, ReactNode> = {
|
||||
metaballs: (
|
||||
<>
|
||||
<circle className={styles.metaballsA} cx='22' cy='50' r='16' />
|
||||
<circle className={styles.metaballsB} cx='78' cy='50' r='16' />
|
||||
</>
|
||||
),
|
||||
relay: (
|
||||
<>
|
||||
<rect x='13' y='28' width='16' height='44' />
|
||||
<rect x='71' y='28' width='16' height='44' />
|
||||
<circle className={styles.relayBall} cx='21' cy='50' r='14' />
|
||||
</>
|
||||
),
|
||||
corners: (
|
||||
<>
|
||||
<rect x='27' y='27' width='46' height='46' />
|
||||
<circle className={styles.cornersA} cx='27' cy='27' r='14' />
|
||||
<circle className={styles.cornersB} cx='73' cy='27' r='14' />
|
||||
<circle className={styles.cornersC} cx='73' cy='73' r='14' />
|
||||
<circle className={styles.cornersD} cx='27' cy='73' r='14' />
|
||||
</>
|
||||
),
|
||||
// burst (Thinking) — four dots fling out of a center cross and are swallowed
|
||||
// at the window edge, then fire again: the Core churning.
|
||||
burst: (
|
||||
<>
|
||||
<rect x='12.5' y='43.75' width='75' height='12.5' />
|
||||
<rect x='43.75' y='12.5' width='12.5' height='75' />
|
||||
<circle cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstUp} cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstDown} cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstLeft} cx='50' cy='50' r='12.5' />
|
||||
<circle className={styles.burstRight} cx='50' cy='50' r='12.5' />
|
||||
</>
|
||||
),
|
||||
compass: (
|
||||
<>
|
||||
<circle cx='50' cy='23' r='14' />
|
||||
<circle cx='23' cy='50' r='14' />
|
||||
<circle cx='77' cy='50' r='14' />
|
||||
<circle cx='50' cy='77' r='14' />
|
||||
<circle className={styles.compassMover} cx='50' cy='23' r='14' />
|
||||
</>
|
||||
),
|
||||
squeeze: (
|
||||
<>
|
||||
{/* Arcs are stroked, not filled — they inherit the group's gradient stroke
|
||||
(so the O is shaded identically to every other shape); the group pins
|
||||
stroke-width to 0, so each arc re-asserts its own 12.5. */}
|
||||
<path d='M 21.36 37.5 A 31.25 31.25 0 0 1 78.64 37.5' fill='none' strokeWidth='12.5' />
|
||||
<path d='M 21.36 62.5 A 31.25 31.25 0 0 0 78.64 62.5' fill='none' strokeWidth='12.5' />
|
||||
<rect className={styles.squeezeBarL} x='15' y='37.5' width='12.5' height='25' />
|
||||
<rect className={styles.squeezeBarR} x='72.5' y='37.5' width='12.5' height='25' />
|
||||
</>
|
||||
),
|
||||
// thinking — a core with small blobs drifting out and back on offset timings:
|
||||
// a restless thought-cloud that never quite resolves. The Core deliberating.
|
||||
thinking: (
|
||||
<>
|
||||
<circle cx='50' cy='50' r='15' />
|
||||
<circle className={styles.thinkA} cx='50' cy='50' r='12' />
|
||||
<circle className={styles.thinkB} cx='50' cy='50' r='12' />
|
||||
<circle className={styles.thinkC} cx='50' cy='50' r='11' />
|
||||
</>
|
||||
),
|
||||
// orb — a single solid disc that nearly fills the frame. Not part of the
|
||||
// random cycle; the loader settles here so it can hand off to a real circular
|
||||
// button (e.g. a send button) without a visible shape pop.
|
||||
orb: <circle cx='50' cy='50' r='42' />,
|
||||
}
|
||||
|
||||
/**
|
||||
* World-aligned status phrase per shape (used when `phase` is set). Each phrase
|
||||
* names what the shape represents in the Sim world, so the words on screen always
|
||||
* match the loader. Keys are the shape names; the comment is the world concept.
|
||||
*/
|
||||
const VARIANT_PHRASE: Record<ThinkingLoaderVariant, string> = {
|
||||
corners: 'Orchestrating…', // Mothership — the Core directing the work
|
||||
squeeze: 'Working…', // Pod — one agent on a task
|
||||
compass: 'In formation…', // Formation — many Pods in parallel
|
||||
metaballs: 'Dispatching…', // Dispatch — sending work out
|
||||
relay: 'Returning…', // Return — work coming back, consolidated
|
||||
burst: 'Thinking…', // Thinking — the Core deliberating
|
||||
thinking: 'Standing by…', // Waiting · Sentinel — holding for a condition
|
||||
orb: 'Ready', // Orb — settled, ready to act
|
||||
}
|
||||
|
||||
export interface ThinkingLoaderProps {
|
||||
/** Pin one pattern. When omitted, the loader morphs between all patterns at random. */
|
||||
variant?: ThinkingLoaderVariant
|
||||
/**
|
||||
* When cycling, open on this pattern (held one beat) before joining the
|
||||
* shared morph timeline. Use `'corners'` (the Mothership shape) to always
|
||||
* begin on the Core. Ignored when `variant` pins a single pattern.
|
||||
*/
|
||||
startVariant?: ThinkingLoaderVariant
|
||||
/**
|
||||
* Stop cycling and morph to the solid `orb` disc — the loader's terminal
|
||||
* resting shape. The goo filter melts the current shape into the orb (no hard
|
||||
* swap), so it can hand off to a real circular element underneath it. Ignored
|
||||
* when `variant` pins a single pattern.
|
||||
*/
|
||||
settle?: boolean
|
||||
/** Rendered square size in px. Defaults to 20. */
|
||||
size?: number
|
||||
/** Optional status text (e.g. "Thinking…") rendered beside the goo with a shimmer sweep. */
|
||||
label?: string
|
||||
/**
|
||||
* Show a world-aligned status phrase that matches the current shape — e.g.
|
||||
* "Dispatching…" under the Dispatch shape — updating as the loader morphs.
|
||||
* Overrides `label`.
|
||||
*/
|
||||
phase?: boolean
|
||||
/**
|
||||
* Phrase/label font size as a fraction of `size`. Defaults to `0.7`. Lower it
|
||||
* when the loader is shown scaled up (e.g. a zoomed hero shot) so the phrase
|
||||
* doesn't read oversized next to the glyph.
|
||||
*/
|
||||
labelRatio?: number
|
||||
/**
|
||||
* Paint the label with the sweeping Claude-style shimmer (default). Set
|
||||
* `false` for a static label in solid `--text-body` ink — no gradient, no
|
||||
* sweep — while the phrase crossfade still applies.
|
||||
*/
|
||||
shimmer?: boolean
|
||||
/** Layout-only classes (margins, alignment). The loader owns its chrome. */
|
||||
className?: string
|
||||
/**
|
||||
* Escape hatch for the ink material: merged onto the SVG, so a caller can
|
||||
* override the gradient/glow CSS vars (`--tl-grad-inner`, `--tl-grad-outer`,
|
||||
* `--tl-glow`) to match a surface it hands off to. Inline, so it beats the
|
||||
* module defaults. Use sparingly — the loader owns its look by default.
|
||||
*/
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Gooey mono thinking indicator shown wherever chat is working.
|
||||
* Ink is near-black on light surfaces and off-white on dark surfaces.
|
||||
*
|
||||
* The goo filter operates on the alpha channel of transparent SVG shapes,
|
||||
* so the loader needs no backdrop and composites on any background. All
|
||||
* patterns share one filtered group, so the default cycling mode melts one
|
||||
* pattern into the next instead of hard-swapping.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ThinkingLoader label='Thinking…' />
|
||||
* ```
|
||||
*/
|
||||
export function ThinkingLoader({
|
||||
variant,
|
||||
startVariant,
|
||||
settle = false,
|
||||
size = 20,
|
||||
label,
|
||||
phase,
|
||||
labelRatio = 0.7,
|
||||
shimmer = true,
|
||||
className,
|
||||
style,
|
||||
}: ThinkingLoaderProps) {
|
||||
// useId emits colons, which break url(#...) filter references — strip them.
|
||||
const id = useId().replace(/[^a-zA-Z0-9-]/g, '')
|
||||
const filterId = `tl-goo-${id}`
|
||||
const clipId = `tl-clip-${id}`
|
||||
const windowClipId = `tl-window-${id}`
|
||||
const gradientId = `tl-grad-${id}`
|
||||
const [cycleVariant, setCycleVariant] = useState<ThinkingLoaderVariant>(
|
||||
startVariant ?? 'metaballs'
|
||||
)
|
||||
const cycling = variant === undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (!cycling) return
|
||||
// Settle: stop the cycle and melt to the terminal orb (goo handles the morph).
|
||||
if (settle) {
|
||||
setCycleVariant('orb')
|
||||
return
|
||||
}
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
// When a startVariant is given, hold it for one min-step so the cycle always
|
||||
// opens on that shape, then join the shared wall-clock morph timeline.
|
||||
let opened = startVariant === undefined
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
const tick = () => {
|
||||
if (!opened) {
|
||||
opened = true
|
||||
setCycleVariant(startVariant as ThinkingLoaderVariant)
|
||||
timeout = setTimeout(tick, STEP_MIN_MS)
|
||||
return
|
||||
}
|
||||
const { variant: next, msUntilNext } = variantAtNow()
|
||||
setCycleVariant(next)
|
||||
timeout = setTimeout(tick, msUntilNext)
|
||||
}
|
||||
tick()
|
||||
return () => clearTimeout(timeout)
|
||||
}, [cycling, startVariant, settle])
|
||||
|
||||
// Phase-lock the CSS animations to the wall clock (set after mount so
|
||||
// server and client markup agree). All instances share the same negative
|
||||
// delay modulus, so their keyframes line up regardless of mount time.
|
||||
const [syncDelay, setSyncDelay] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
setSyncDelay(`-${Date.now() % SYNC_PERIOD_MS}ms`)
|
||||
}, [])
|
||||
|
||||
const shown = variant ?? cycleVariant
|
||||
// `phase` shows the world phrase for the shape on screen; otherwise the
|
||||
// caller's static label (if any). Cycling, it updates as the shape morphs.
|
||||
const displayLabel = phase ? VARIANT_PHRASE[shown] : label
|
||||
|
||||
// Crossfade the phrase when it changes: the outgoing phrase rises and fades
|
||||
// out while the incoming one rises and fades in, so they rotate smoothly
|
||||
// instead of snapping. The shimmer keeps running on the text underneath.
|
||||
const [shownLabel, setShownLabel] = useState(displayLabel)
|
||||
const [exitingLabel, setExitingLabel] = useState<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (displayLabel === shownLabel) return
|
||||
setExitingLabel(shownLabel)
|
||||
setShownLabel(displayLabel)
|
||||
}, [displayLabel, shownLabel])
|
||||
useEffect(() => {
|
||||
if (!exitingLabel) return
|
||||
const timeout = setTimeout(() => setExitingLabel(undefined), 420)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [exitingLabel])
|
||||
|
||||
const stages = cycling ? VARIANTS : [shown]
|
||||
|
||||
const loader = (
|
||||
<svg
|
||||
role={displayLabel ? undefined : 'status'}
|
||||
aria-label={displayLabel ? undefined : 'Thinking'}
|
||||
aria-hidden={displayLabel ? true : undefined}
|
||||
viewBox='0 0 100 100'
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn(styles.frame, !displayLabel && className)}
|
||||
style={{
|
||||
...(syncDelay ? ({ '--tl-sync': syncDelay } as CSSProperties) : {}),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
{/* sRGB so the radial gradient fill keeps its authored midtones
|
||||
through the blur (linearRGB would wash the gradient out). The goo
|
||||
crush runs first; the inner glow then rides the merged silhouette's
|
||||
edge — a soft white inset, per the Figma loader spec. */}
|
||||
<filter
|
||||
id={filterId}
|
||||
x='-30%'
|
||||
y='-30%'
|
||||
width='160%'
|
||||
height='160%'
|
||||
colorInterpolationFilters='sRGB'
|
||||
>
|
||||
<feGaussianBlur in='SourceGraphic' stdDeviation='5' result='blur' />
|
||||
<feColorMatrix
|
||||
in='blur'
|
||||
values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9'
|
||||
result='goo'
|
||||
/>
|
||||
{/* Inner shadow from the goo silhouette's alpha (Figma technique:
|
||||
blur the alpha, subtract it from itself to leave an inner ring,
|
||||
tint white). stdDeviation 4.86 = the spec's 3.5 scaled 72→100. */}
|
||||
<feColorMatrix
|
||||
in='goo'
|
||||
type='matrix'
|
||||
values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0'
|
||||
result='gooAlpha'
|
||||
/>
|
||||
<feGaussianBlur in='gooAlpha' stdDeviation='4.86' result='innerBlur' />
|
||||
<feComposite
|
||||
in='innerBlur'
|
||||
in2='gooAlpha'
|
||||
operator='arithmetic'
|
||||
k2='-1'
|
||||
k3='1'
|
||||
result='innerMask'
|
||||
/>
|
||||
{/* Glow color + per-theme opacity ride a CSS var (light 0.6 / dark
|
||||
0.9) so one filter serves both themes. */}
|
||||
<feFlood className={styles.glow} result='glowColor' />
|
||||
<feComposite in='glowColor' in2='innerMask' operator='in' result='glow' />
|
||||
<feMerge>
|
||||
<feMergeNode in='goo' />
|
||||
<feMergeNode in='glow' />
|
||||
</feMerge>
|
||||
</filter>
|
||||
{/* Radial gradient (center → edge), theme stops via CSS vars. Matches
|
||||
the Figma loader: light #4F4F4F→#6F6F6F, dark #A7A7A7→#D6D6D6.
|
||||
objectBoundingBox (default units) so EACH blob is shaded on its own
|
||||
box — Figma fits the gradient per shape, so small/offset dots read
|
||||
glossy (center dark, edge light) instead of flat. */}
|
||||
<radialGradient id={gradientId} cx='0.5' cy='0.5' r='0.5'>
|
||||
<stop className={styles.gradInner} />
|
||||
<stop offset='1' className={styles.gradOuter} />
|
||||
</radialGradient>
|
||||
{/* Shapes clip BEFORE the goo filter, so anything exiting the frame
|
||||
melts into the edge instead of getting a hard post-filter cut. */}
|
||||
<clipPath id={clipId}>
|
||||
<rect width='100' height='100' />
|
||||
</clipPath>
|
||||
{/* The original burst loader hid its flying dots under a 10px border,
|
||||
swallowing them at the inner window — this clip reproduces it. */}
|
||||
<clipPath id={windowClipId}>
|
||||
<rect x='12.5' y='12.5' width='75' height='75' />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g
|
||||
filter={`url(#${filterId})`}
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth={0}
|
||||
>
|
||||
{stages.map((v) => (
|
||||
<g
|
||||
key={v}
|
||||
clipPath={`url(#${v === 'burst' ? windowClipId : clipId})`}
|
||||
className={cn(styles.stage, v === shown && styles.stageActive)}
|
||||
>
|
||||
{VARIANT_SHAPES[v]}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
if (!displayLabel) return loader
|
||||
|
||||
return (
|
||||
<output
|
||||
className={cn(styles.labelRow, className)}
|
||||
style={
|
||||
{
|
||||
'--tl-label-size': `${size * labelRatio}px`,
|
||||
'--tl-label-gap': `${size * 0.4}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{loader}
|
||||
<span className={styles.labelStack}>
|
||||
{exitingLabel ? (
|
||||
<span key={exitingLabel} className={cn(styles.labelLayer, styles.labelOut)}>
|
||||
<span className={shimmer ? styles.label : styles.labelStatic}>{exitingLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span key={shownLabel} className={cn(styles.labelLayer, styles.labelIn)}>
|
||||
<span className={shimmer ? styles.label : styles.labelStatic}>{shownLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
</output>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user