motion study - free to use
Nine States of one line
Nine States of one line
A process indicator drawn as a single continuous stroke. It never changes form, only behvaiour - the dame line listens, diverges, converges, and settles.
A process indicator drawn as a single continuous stroke. It never changes form, only behvaiour - the dame line listens, diverges, converges, and settles.
01
IdleNothing asked yet. The line breathes.
02
ListeningTaking input. Amplitude follows the voice.
03
GatheringPulling material inward, turn by turn.
04
DivergingThree directions held open at once.
05
ThinkingA knot turning over on itself.
06
ConvergingA coil closing on the answer.
07
RefiningSmall corrections at high frequency.
08
TestingCrossing back over the same path.
09
ShippedSettled. One point still travelling.
01
IdleNothing asked yet. The line breathes.
02
ListeningTaking input. Amplitude follows the voice.
03
GatheringPulling material inward, turn by turn.
04
DivergingThree directions held open at once.
05
ThinkingA knot turning over on itself.
06
ConvergingA coil closing on the answer.
07
RefiningSmall corrections at high frequency.
08
TestingCrossing back over the same path.
09
ShippedSettled. One point still travelling.
Playground
Pick a state, size it, then take the code — web, React Native, or SwiftUI.
State
Size
Weight
Speed 1.00×
Thinking
// Thread — Thinking
// Single-stroke process indicator. React + Canvas, no dependencies. MIT.
import { useEffect, useRef } from "react"
const TAU = Math.PI * 2
const SEGMENTS = 150
const CONFIG = { closed: true, head: 0.3, tail: 0.22, base: 0.2 }
function curve(u, t) {
const a = u * TAU
const x = (Math.sin(a) + 2 * Math.sin(2 * a)) / 3
const y = (Math.cos(a) - 2 * Math.cos(2 * a)) / 3
const p = t * 0.45
return [x * Math.cos(p) - y * Math.sin(p), x * Math.sin(p) + y * Math.cos(p)]
}
export default function Thread({
size = 96,
color = "#F2F3F3",
weight = 1.4,
speed = 1,
}) {
const ref = useRef(null)
useEffect(() => {
const canvas = ref.current
const ctx = canvas.getContext("2d")
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = size * dpr
canvas.height = size * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
let raf = 0
const t0 = performance.now()
const frame = (now) => {
const t = ((now - t0) / 1000) * speed
const R = (size / 2) * 0.78
ctx.clearRect(0, 0, size, size)
ctx.save()
ctx.translate(size / 2, size / 2)
ctx.lineCap = "round"
ctx.strokeStyle = color
const pts = []
for (let i = 0; i <= SEGMENTS; i++) {
const [x, y] = curve(i / SEGMENTS, t)
pts.push([x * R, y * R])
}
// bloom: the whole path once, wide and faint
ctx.globalAlpha = 0.07
ctx.lineWidth = weight * 3.4
ctx.beginPath()
ctx.moveTo(pts[0][0], pts[0][1])
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
if (CONFIG.closed) ctx.closePath()
ctx.stroke()
// the stroke, with a comet head running along it
const head = (t * CONFIG.head) % 1
const last = CONFIG.closed ? SEGMENTS : SEGMENTS - 1
for (let i = 0; i < last; i++) {
let d = head - i / SEGMENTS
if (d < 0) d += 1
const boost = Math.exp(-d / CONFIG.tail)
ctx.globalAlpha = Math.min(1, CONFIG.base + (1 - CONFIG.base) * boost)
ctx.lineWidth = weight * (0.6 + 0.85 * boost)
const a = pts[i]
const b = pts[(i + 1) % pts.length]
ctx.beginPath()
ctx.moveTo(a[0], a[1])
ctx.lineTo(b[0], b[1])
ctx.stroke()
}
ctx.restore()
raf = requestAnimationFrame(frame)
}
raf = requestAnimationFrame(frame)
return () => cancelAnimationFrame(raf)
}, [size, color, weight, speed])
return <canvas ref={ref} style={{ width: size, height: size }} />
}Thread.jsxReact 17+ · canvas 2D · no packagesMIT licence — no attribution required
Playground
Pick a state, size it, then take the code — web, React Native, or SwiftUI.
State
Size
Weight
Speed 1.00×
Thinking
// Thread — Thinking
// Single-stroke process indicator. React + Canvas, no dependencies. MIT.
import { useEffect, useRef } from "react"
const TAU = Math.PI * 2
const SEGMENTS = 150
const CONFIG = { closed: true, head: 0.3, tail: 0.22, base: 0.2 }
function curve(u, t) {
const a = u * TAU
const x = (Math.sin(a) + 2 * Math.sin(2 * a)) / 3
const y = (Math.cos(a) - 2 * Math.cos(2 * a)) / 3
const p = t * 0.45
return [x * Math.cos(p) - y * Math.sin(p), x * Math.sin(p) + y * Math.cos(p)]
}
export default function Thread({
size = 96,
color = "#F2F3F3",
weight = 1.4,
speed = 1,
}) {
const ref = useRef(null)
useEffect(() => {
const canvas = ref.current
const ctx = canvas.getContext("2d")
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = size * dpr
canvas.height = size * dpr
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
let raf = 0
const t0 = performance.now()
const frame = (now) => {
const t = ((now - t0) / 1000) * speed
const R = (size / 2) * 0.78
ctx.clearRect(0, 0, size, size)
ctx.save()
ctx.translate(size / 2, size / 2)
ctx.lineCap = "round"
ctx.strokeStyle = color
const pts = []
for (let i = 0; i <= SEGMENTS; i++) {
const [x, y] = curve(i / SEGMENTS, t)
pts.push([x * R, y * R])
}
// bloom: the whole path once, wide and faint
ctx.globalAlpha = 0.07
ctx.lineWidth = weight * 3.4
ctx.beginPath()
ctx.moveTo(pts[0][0], pts[0][1])
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1])
if (CONFIG.closed) ctx.closePath()
ctx.stroke()
// the stroke, with a comet head running along it
const head = (t * CONFIG.head) % 1
const last = CONFIG.closed ? SEGMENTS : SEGMENTS - 1
for (let i = 0; i < last; i++) {
let d = head - i / SEGMENTS
if (d < 0) d += 1
const boost = Math.exp(-d / CONFIG.tail)
ctx.globalAlpha = Math.min(1, CONFIG.base + (1 - CONFIG.base) * boost)
ctx.lineWidth = weight * (0.6 + 0.85 * boost)
const a = pts[i]
const b = pts[(i + 1) % pts.length]
ctx.beginPath()
ctx.moveTo(a[0], a[1])
ctx.lineTo(b[0], b[1])
ctx.stroke()
}
ctx.restore()
raf = requestAnimationFrame(frame)
}
raf = requestAnimationFrame(frame)
return () => cancelAnimationFrame(raf)
}, [size, color, weight, speed])
return <canvas ref={ref} style={{ width: size, height: size }} />
}Thread.jsxReact 17+ · canvas 2D · no packagesMIT licence — no attribution required