feat(ui): try activity spinner variations from Knobs (#57903)

This commit is contained in:
Kit Langton
2026-10-21 15:42:00 -07:00
committed by GitHub
Unverified
parent 4d38fcda8c
commit db7968d6bb
6 changed files with 530 additions and 47 deletions
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test"
import { spinnerDots, spinnerKeyframes, spinnerVariants } from "./spinner-variants"
test("every variation's dots fit the viewBox and light within one loop", () => {
Object.values(spinnerVariants).forEach((variant) => {
const dots = spinnerDots(variant)
expect(dots.length).toBe(variant.layout === "orb" ? 37 : variant.span ** 2)
dots.forEach((dot) => {
expect(dot.x).toBeGreaterThanOrEqual(0)
expect(dot.x + dot.size).toBeLessThanOrEqual(16)
expect(dot.at).toBeGreaterThanOrEqual(0)
expect(dot.at).toBeLessThan(1)
expect(dot.still).toBeGreaterThanOrEqual(variant.dim)
expect(dot.still).toBeLessThanOrEqual(1)
})
})
})
test("a border snake rests its middle dots and visits each edge dot once", () => {
const dots = spinnerDots(spinnerVariants.snake)
const edge = dots.filter((dot) => !dot.hold)
expect(dots.filter((dot) => dot.hold === "rest")).toHaveLength(4)
expect(new Set(edge.map((dot) => dot.at)).size).toBe(12)
})
test("keyframes run from the dim rest to fully lit", () => {
expect(spinnerKeyframes("spinner-radar", spinnerVariants.radar)).toBe(
"@keyframes spinner-radar{0%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}3%{opacity:1;animation-timing-function:cubic-bezier(.45,0,.2,1)}51%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}100%{opacity:0.2;animation-timing-function:cubic-bezier(.45,0,.2,1)}}",
)
})
@@ -0,0 +1,320 @@
// Dot-matrix variations of the activity spinner, tried from Knobs → Experiments → "Activity spinner". Each variation
// is plain data: a dot layout, a keyframe shape, and a pattern that turns each dot's place (angle, distance, path
// index…) into its animation delay. Animation is CSS only; every dot shares the variation's loop, so pinning start
// times with syncSpinPhase keeps every spinner on screen in phase. Paste replacements from the prototype's
// "Try in app" list.
export type SpinnerPattern =
| "sweep"
| "bar"
| "diagonal"
| "ripple"
| "twinkle"
| "border"
| "breathe"
| "spiral"
| "rain"
| "pulse"
export type SpinnerVariant = {
label: string
/** "grid" is span × span; "orb" is the 37-dot circle (rows of 3, 5, 7, 7, 7, 5, 3). */
layout: "grid" | "orb"
span: number
/** Dot side and gap, in a 16-unit viewBox. */
dot: number
gap: number
/** Seconds per loop at 1×. */
loop: number
pattern: SpinnerPattern
/** How far apart neighbouring dots light, for the patterns that spread outward. */
spread?: number
/** The centre dot stays lit (radars and scan lines). */
steady?: boolean
/** Resting opacity of an unlit dot. */
dim: number
/** The keyframe shape: [percent of the loop, level from 0 (dim) to 1 (lit)]. */
stops: readonly (readonly [number, number])[]
}
export const spinnerVariants = {
radar: {
label: "Soft Radar",
layout: "grid",
span: 5,
dot: 2,
gap: 1,
loop: 1.8,
pattern: "sweep",
steady: true,
dim: 0.2,
stops: [
[0, 0],
[3, 1],
[51, 0],
[100, 0],
],
},
orbRadar: {
label: "Orb Radar",
layout: "orb",
span: 7,
dot: 1.5,
gap: 0.72,
loop: 2.2,
pattern: "sweep",
steady: true,
dim: 0.2,
stops: [
[0, 0],
[3, 1],
[62, 0],
[100, 0],
],
},
ripple: {
label: "Centre Ripple",
layout: "grid",
span: 5,
dot: 2,
gap: 1,
loop: 1.5,
pattern: "ripple",
spread: 0.28,
dim: 0.2,
stops: [
[0, 0],
[3, 1],
[37, 0],
[100, 0],
],
},
snake: {
label: "Border Snake",
layout: "grid",
span: 4,
dot: 2.6,
gap: 1.3,
loop: 1.2,
pattern: "border",
dim: 0.2,
stops: [
[0, 0],
[3, 1],
[33, 0],
[100, 0],
],
},
spiral: {
label: "Spiral Fill",
layout: "grid",
span: 5,
dot: 2,
gap: 1,
loop: 3,
pattern: "spiral",
dim: 0.2,
stops: [
[0, 0],
[3, 1],
[48, 1],
[54, 0],
[100, 0],
],
},
breathe: {
label: "Breathing Grid",
layout: "grid",
span: 3,
dot: 3.6,
gap: 2,
loop: 2,
pattern: "breathe",
spread: 0.05,
dim: 0.2,
stops: [
[0, 0],
[8, 0],
[50, 1],
[92, 0],
[100, 0],
],
},
} satisfies Record<string, SpinnerVariant>
export type SpinnerDot = {
x: number
y: number
size: number
/** Where in the loop the dot lights, 0–1. */
at: number
/** The dot's loop as a multiple of the variation's (twinkle varies it). */
scale: number
/** Always lit, or never animated (the resting middle of a border pattern). */
hold?: "lit" | "rest"
/** Opacity for reduced motion: the pattern frozen a fifth of the way into its loop. */
still: number
}
type Cell = { c: number; r: number; gx: number; gy: number }
/** The dots of a variation, positioned in a 16-unit viewBox. */
export function spinnerDots(variant: SpinnerVariant): SpinnerDot[] {
const cells = variant.layout === "orb" ? orb() : grid(variant.span)
const extent = variant.span * variant.dot + (variant.span - 1) * variant.gap
const fit = Math.min(1, 15 / extent)
const size = variant.dot * fit
const pitch = (variant.dot + variant.gap) * fit
const order = variant.pattern === "spiral" ? spiralOrder(variant.span) : []
const ring = variant.pattern === "border" ? borderOrder(variant.span) : []
const random = seeded(4242)
const offsets = Array.from({ length: variant.span }, () => random())
return cells.map((cell, index) => {
const centre = cell.gx === 0 && cell.gy === 0
const twinkle =
variant.pattern === "twinkle" ? { at: random(), scale: [1, 1.35, 1.7][Math.floor(random() * 3)]! } : undefined
const at = (() => {
const spread = variant.spread ?? 1
const dist = Math.hypot(cell.gx, cell.gy)
switch (variant.pattern) {
case "sweep":
return angle(cell)
case "bar":
return frac(angle(cell) * 2)
case "diagonal":
return (cell.c + cell.r) / (2 * variant.span - 1)
case "ripple":
case "breathe":
case "pulse":
return frac(dist * spread)
case "twinkle":
return twinkle!.at
case "border":
return Math.max(0, ring.indexOf(cell.r * variant.span + cell.c)) / ring.length
case "spiral":
return order.indexOf(cell.r * variant.span + cell.c) / (2 * order.length)
case "rain":
return frac(offsets[cell.c]! + cell.r * spread * 0.11)
}
})()
const hold =
variant.steady && centre
? "lit"
: variant.pattern === "border" && !ring.includes(cell.r * variant.span + cell.c)
? "rest"
: undefined
const level = hold === "lit" ? 1 : hold === "rest" ? 0 : levelAt(variant.stops, frac(0.22 - at) * 100)
return {
x: round(8 + cell.gx * pitch - size / 2),
y: round(8 + cell.gy * pitch - size / 2),
size: round(size),
at: round(at),
scale: twinkle?.scale ?? 1,
hold,
still: round(variant.dim + (1 - variant.dim) * level),
}
})
}
/** The variation's keyframes, named `name`. */
export function spinnerKeyframes(name: string, variant: SpinnerVariant) {
const frames = variant.stops
.map(
([percent, level]) =>
`${round(percent)}%{opacity:${round(variant.dim + (1 - variant.dim) * level)};animation-timing-function:cubic-bezier(.45,0,.2,1)}`,
)
.join("")
return `@keyframes ${name}{${frames}}`
}
function grid(span: number): Cell[] {
return Array.from({ length: span * span }, (_, index) => {
const c = index % span
const r = Math.floor(index / span)
return { c, r, gx: c - (span - 1) / 2, gy: r - (span - 1) / 2 }
})
}
function orb(): Cell[] {
return [3, 5, 7, 7, 7, 5, 3].flatMap((count, r) =>
Array.from({ length: count }, (_, j) => {
const c = (7 - count) / 2 + j
return { c, r, gx: c - 3, gy: r - 3 }
}),
)
}
// Clockwise from the top-left corner, as indices into a span × span grid.
function borderOrder(span: number) {
const top = Array.from({ length: span }, (_, c) => c)
const right = Array.from({ length: span - 2 }, (_, r) => (r + 1) * span + span - 1)
const bottom = Array.from({ length: span }, (_, c) => (span - 1) * span + span - 1 - c)
const left = Array.from({ length: span - 2 }, (_, r) => (span - 2 - r) * span)
return [...top, ...right, ...bottom, ...left]
}
// Inward from the top-left corner, clockwise.
function spiralOrder(span: number) {
const order: number[] = []
const edges = { top: 0, left: 0, bottom: span - 1, right: span - 1 }
while (edges.top <= edges.bottom && edges.left <= edges.right) {
for (let c = edges.left; c <= edges.right; c++) order.push(edges.top * span + c)
edges.top++
for (let r = edges.top; r <= edges.bottom; r++) order.push(r * span + edges.right)
edges.right--
if (edges.top <= edges.bottom) for (let c = edges.right; c >= edges.left; c--) order.push(edges.bottom * span + c)
edges.bottom--
if (edges.left <= edges.right) for (let r = edges.bottom; r >= edges.top; r--) order.push(r * span + edges.left)
edges.left++
}
return order
}
// Clockwise from 12 o'clock, 0–1.
function angle(cell: Cell) {
return frac(Math.atan2(cell.gx, -cell.gy) / (2 * Math.PI))
}
function levelAt(stops: SpinnerVariant["stops"], percent: number) {
const next = stops.findIndex(([at]) => at >= percent)
if (next <= 0) return stops[0]?.[1] ?? 0
const [p0, l0] = stops[next - 1]!
const [p1, l1] = stops[next]!
return l0 + ((l1 - l0) * (percent - p0)) / (p1 - p0 || 1)
}
function seeded(seed: number) {
const state = { value: seed }
return () => {
state.value = (state.value * 16807) % 2147483647
return (state.value - 1) / 2147483646
}
}
function frac(value: number) {
return value - Math.floor(value)
}
function round(value: number) {
return Math.round(value * 1000) / 1000
}
+16
View File
@@ -32,3 +32,19 @@
display: block;
}
}
/* Dot-matrix variations (Knobs → Experiments → Activity spinner). Each dot rests at its frozen pose, so reduced
motion only has to stop the animation. */
[data-slot="spinner-dots"] rect {
opacity: var(--still);
}
/* syncSpinPhase marks spinners out of view; `!important` beats the inline animation each dot carries. */
[data-slot="spinner-dots"][data-offscreen] rect {
animation-play-state: paused !important;
}
:root[data-motion="reduce"] [data-slot="spinner-dots"] rect {
/* Beats the inline animation each dot carries. */
animation: none !important;
}
+35 -27
View File
@@ -1,36 +1,42 @@
// @ts-nocheck
import * as mod from "./spinner"
import { create } from "../storybook/scaffold"
import { Spinner, type SpinnerVariantName } from "./spinner"
import { spinnerVariants } from "./spinner-variants"
const docs = `### Overview
Animated loading indicator for inline or page-level loading states.
Use with \`Button\` or in empty states.
### API
- Accepts standard SVG props (class, style).
Animated loading indicator for inline or page-level loading states. Tabs, \`Loader\`, \`ActivityRing\` and the
titlebar update button all render it.
### Variants and states
- Single default animation style.
- Today: the 5×5 radar sweep from an animated PNG mask.
- Dot-matrix variations to try from Knobs → Experiments → Activity spinner; a \`variant\` prop pins one.
### Behavior
- Animation is CSS-driven via data attributes.
- CSS animation only. Every dot of a variation shares one loop, and \`syncSpinPhase\` pins every instance to the
same start, so spinners on screen stay in phase.
- Reduced motion freezes each variation on a still pose.
### Accessibility
- Use alongside text or aria-live regions to convey loading state.
### Theming/tokens
- Uses \`data-component="spinner"\` for styling hooks.
`
const story = create({ title: "UI/Spinner", mod })
type Args = { variant: SpinnerVariantName; speed: number; size: number }
export default {
title: "UI/Spinner",
id: "components-spinner",
component: story.meta.component,
component: Spinner,
tags: ["autodocs"],
args: { variant: "today", speed: 1, size: 16 },
argTypes: {
variant: { control: "select", options: ["today", ...Object.keys(spinnerVariants)] },
speed: { control: { type: "range", min: 0.25, max: 2, step: 0.05 } },
size: { control: { type: "range", min: 12, max: 48, step: 1 } },
},
render: (args: Args) => (
<Spinner
variant={args.variant}
style={{ width: `${args.size}px`, height: `${args.size}px`, "--spinner-time": String(1 / args.speed) }}
/>
),
parameters: {
docs: {
description: {
@@ -40,14 +46,16 @@ export default {
},
}
export const Basic = story.Basic
export const Today = { args: { variant: "today" } }
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "16px", "align-items": "center" }}>
<mod.Spinner style={{ width: "12px", height: "12px" }} />
<mod.Spinner style={{ width: "20px", height: "20px" }} />
<mod.Spinner style={{ width: "28px", height: "28px" }} />
</div>
),
}
export const SoftRadar = { args: { variant: "radar" } }
export const OrbRadar = { args: { variant: "orbRadar" } }
export const CentreRipple = { args: { variant: "ripple" } }
export const BorderSnake = { args: { variant: "snake" } }
export const SpiralFill = { args: { variant: "spiral" } }
export const BreathingGrid = { args: { variant: "breathe" } }
+95 -14
View File
@@ -1,19 +1,100 @@
import { type ComponentProps } from "solid-js"
import { createSignal, For, Show, splitProps, type ComponentProps } from "solid-js"
import { syncSpinPhase } from "../hooks/spin-phase"
import { motionTuning } from "../motion-tuning"
import { spinnerDots, spinnerKeyframes, spinnerVariants, type SpinnerVariant } from "./spinner-variants"
export type SpinnerVariantName = "today" | keyof typeof spinnerVariants
// Experiment: dot-matrix variations to try in place of today's spinner, everywhere it shows (tabs, loaders, the
// update button), before one ships.
const choice = motionTuning.choice("spinner.variant", {
label: "Activity spinner",
group: "Experiments",
value: "today",
// SAFETY: built from spinnerVariants' own keys, plus "today".
labels: Object.fromEntries([
["today", "Today"],
...Object.entries(spinnerVariants).map(([name, variant]) => [name, variant.label]),
]) as Record<SpinnerVariantName, string>,
})
const [chosen, setChosen] = createSignal(choice())
motionTuning.subscribe(() => setChosen(choice()))
// Each variation's keyframes are added to the document once, the first time one renders.
const added = new Set<string>()
export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string; variant?: SpinnerVariantName }) {
const [local, rest] = splitProps(props, ["variant", "class", "classList", "data-component"])
const name = () => local.variant ?? chosen()
// SAFETY: "today" is the only name that isn't a key of spinnerVariants, and it maps to undefined.
const variant = (): SpinnerVariant | undefined => spinnerVariants[name() as keyof typeof spinnerVariants]
const classList = () => ({ ...local.classList, [local.class ?? ""]: !!local.class })
export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string }) {
return (
<svg
{...props}
viewBox="0 0 16 16"
data-component={props["data-component"] ?? "spinner"}
data-slot="spinner-grid"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
}}
fill="currentColor"
// Keyed, so switching variations builds a fresh SVG: its dots, keyframes and phase pin all come from the new one.
<Show
when={variant()}
keyed
fallback={
<svg
{...rest}
viewBox="0 0 16 16"
data-component={local["data-component"] ?? "spinner"}
data-slot="spinner-grid"
classList={classList()}
fill="currentColor"
>
<rect data-reduced-motion x={7} y={7} width={2} height={2} />
</svg>
}
>
<rect data-reduced-motion x={7} y={7} width={2} height={2} />
</svg>
{(current) => {
const keyframes = `spinner-${name()}`
if (!added.has(keyframes)) {
added.add(keyframes)
const style = document.createElement("style")
style.textContent = spinnerKeyframes(keyframes, current)
document.head.append(style)
}
return (
<svg
{...rest}
ref={syncSpinPhase}
viewBox="0 0 16 16"
data-component={local["data-component"] ?? "spinner"}
data-slot="spinner-dots"
data-variant={name()}
classList={classList()}
fill="currentColor"
>
<For each={spinnerDots(current)}>
{(dot) => {
const loop = current.loop * dot.scale
return (
<rect
x={dot.x}
y={dot.y}
width={dot.size}
height={dot.size}
rx={dot.size * 0.24}
style={{
"--still": dot.hold === "lit" ? 1 : dot.hold === "rest" ? current.dim : dot.still,
animation: dot.hold
? undefined
: `${keyframes} calc(${loop}s * var(--spinner-time, 1)) linear calc(${-loop * (1 - dot.at)}s * var(--spinner-time, 1)) infinite`,
}}
/>
)
}}
</For>
</svg>
)
}}
</Show>
)
}
+32 -6
View File
@@ -2,17 +2,43 @@ import { onCleanup } from "solid-js"
// ResizeObserver delivers after style and layout but before paint, so a spinner that mounts or leaves `display: none`
// (which restarts its CSS animation) paints its first frame already in phase.
const observer =
const resize =
typeof ResizeObserver === "undefined"
? undefined
: new ResizeObserver((entries) => entries.forEach((entry) => pinSpinPhase(entry.target)))
: new ResizeObserver((entries) => entries.forEach((entry) => pinVisible(entry.target)))
/** Use as a `ref` on a looping indicator so every instance on screen shares one phase. */
// Spinners scrolled or clipped out of view, under a `display: none` or `content-visibility: hidden` ancestor, or sized
// to nothing get `data-offscreen`, which their CSS uses to pause. Coming back, they resume and re-pin to the shared
// phase. `visibility: hidden` and `opacity: 0` still count as visible.
const visibility =
typeof IntersectionObserver === "undefined"
? undefined
: new IntersectionObserver((entries) =>
entries.forEach((entry) => {
entry.target.toggleAttribute("data-offscreen", !entry.isIntersecting)
pinVisible(entry.target)
}),
)
/** Use as a `ref` on a looping indicator so every instance on screen shares one phase and pauses while hidden. */
export function syncSpinPhase(el: Element) {
observer?.observe(el)
resize?.observe(el)
visibility?.observe(el)
// Turning reduced motion back off restarts the animation without resizing anything.
el.addEventListener("animationstart", () => pinSpinPhase(el))
onCleanup(() => observer?.unobserve(el))
el.addEventListener("animationstart", () => pinVisible(el))
onCleanup(() => {
resize?.unobserve(el)
visibility?.unobserve(el)
})
}
// Setting the start time of a paused CSS animation resumes it and makes it ignore `animation-play-state` from then on,
// so a paused spinner is left alone until it is visible. `getAnimations` flushes style, so a spinner that has just
// lost `data-offscreen` is running again by the time it is pinned.
function pinVisible(el: Element) {
if (el.hasAttribute("data-offscreen")) return
pinSpinPhase(el)
}
// Starting every infinite loop at the document timeline's origin puts all instances of one animation in phase.