StageSelect
A select a stage draws, list and all, in whatever look the stage around it has.
SourcePackage @weft-js/warp
Preview
import { DitherStage, StageSelect } from '@weft-js/warp'export function Dither() { return ( <DitherStage ground={0.3} style="padding:28px;min-block-size:300px"><Choose /></DitherStage> )}const min = 60_000const Choose = () => ( <div data-skin="card" style="display:grid;gap:14px;inline-size:min(340px, 100%)"> <label data-part="label">Region<StageSelect name="region" options={['eu-west', 'us-east', 'ap-south', 'sa-east']} /></label> <label data-part="label">Plan<StageSelect name="plan" value="team" options={[{ value: 'hobby', label: 'Hobby' }, { value: 'team', label: 'Team' }, { value: 'scale', label: 'Scale (talk to us)', disabled: true }]} /></label> </div>)Installation
npm install @weft-js/warpUsage
import { StageSelect } from '@weft-js/warp'<DitherStage ground={0.3} style="padding:28px;min-block-size:300px"><Choose /></DitherStage>Looks
The same markup on three stages. The list floats in the top layer as a stage of its own and paints with the look it opened from, so nothing clips it and nothing under it shows through.
Flat
Painted by the menu and option painters in this file's flat look.
import { CanvasStage, StageSelect, Text } from '@weft-js/warp'import type { Scene, StagePainter, StagePiece } from '@weft-js/warp'export function Flat() { return ( <CanvasStage {...LOOKS.flat.stage} style={FLAT_CHOOSE}><Choose /></CanvasStage> )}const wobble = (i: number) => Math.sin(i * 12.9898) * 43758.5453 % 1const min = 60_000type LookName = 'flat' | 'sketch' | 'neon'type Style = { ink: string paper: string accent: string soft: string /** Text on a filled button or chip. */ onFill: string /** Pixels of wobble: 0 draws clean lines. */ rough: number /** Glow, in pixels of blur. */ glow: number /** Fills as pencil hatching instead of solid. */ hatch: boolean shadow: 'soft' | 'offset' | 'none' ground: (s: Scene) => void art: (p: StagePiece, s: Scene) => void type: (p: StagePiece, s: Scene) => void}const seeds = new WeakMap<Element, number>()let seedNext = 1const seedOf = (el: Element) => { let v = seeds.get(el); if (v === undefined) seeds.set(el, (v = seedNext++ * 7919)); return v }const rng = (seed: number) => () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647 - 0.5 }function outline(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number, st: Style, seed: number) { ctx.beginPath() if (!st.rough) return void ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2)) const j = rng(seed), k = st.rough, pts: [number, number][] = [] const edge = (ax: number, ay: number, bx: number, by: number) => { const n = Math.max(2, Math.round(Math.hypot(bx - ax, by - ay) / 28)); for (let i = 0; i < n; i++) pts.push([ax + ((bx - ax) * i) / n + j() * k, ay + ((by - ay) * i) / n + j() * k]) } edge(x, y, x + w, y); edge(x + w, y, x + w, y + h); edge(x + w, y + h, x, y + h); edge(x, y + h, x, y) ctx.moveTo(pts[0]![0], pts[0]![1]) for (let i = 1; i <= pts.length; i++) { const [px, py] = pts[i % pts.length]!; ctx.lineTo(px, py) } ctx.lineTo(pts[0]![0] + j() * k, pts[0]![1] + j() * k)}function stroke(ctx: CanvasRenderingContext2D, color: string, width: number, st: Style) { ctx.strokeStyle = color ctx.lineWidth = width ctx.lineJoin = ctx.lineCap = 'round' if (st.glow) { ctx.shadowColor = color; ctx.shadowBlur = st.glow } ctx.stroke() ctx.shadowBlur = 0}function paint(ctx: CanvasRenderingContext2D, color: string, st: Style, box: [number, number, number, number], density = 1) { if (!st.hatch) { ctx.fillStyle = color; ctx.fill(); return } // Pencil hatching: the shape clips a run of diagonal strokes. const [x, y, w, h] = box, gap = 7 / density ctx.save() ctx.clip() ctx.beginPath() for (let d = -h; d < w; d += gap) { ctx.moveTo(x + d, y + h); ctx.lineTo(x + d + h, y) } ctx.strokeStyle = color ctx.lineWidth = 1.6 ctx.globalAlpha = 0.55 ctx.stroke() ctx.restore()}function lookKit(st: Style) { const c = (s: Scene, name: string) => s.color(name) const ring = (p: StagePiece, s: Scene) => { if (p.focus < 0.5) return const ctx = s.ctx ctx.setLineDash([6, 5]) ctx.lineDashOffset = -s.time * 24 outline(ctx, p.x - 4, p.y - 4, p.w + 8, p.h + 8, p.r + 4, st, seedOf(p.el) + 3) stroke(ctx, c(s, st.accent), 2, st) ctx.setLineDash([]) } const lift = (p: StagePiece, s: Scene, depth: number) => { const ctx = s.ctx if (st.shadow === 'soft') { ctx.shadowColor = 'rgb(0 0 0 / 0.2)'; ctx.shadowBlur = depth * 3; ctx.shadowOffsetY = depth; outline(ctx, p.x, p.y, p.w, p.h, p.r, st, seedOf(p.el)); ctx.fillStyle = c(s, st.paper); ctx.fill(); ctx.shadowColor = 'transparent' } if (st.shadow === 'offset') { outline(ctx, p.x + depth, p.y + depth, p.w, p.h, p.r, st, seedOf(p.el) + 1); paint(ctx, c(s, st.soft), st, [p.x + depth, p.y + depth, p.w, p.h], 0.7) } } // `solid` keeps a surface's paper whole: a panel or a list floats over the page, and a field's words need clean ground. const body = (p: StagePiece, s: Scene, fill: string, line: string, width = 1.5, solid = false) => { const ctx = s.ctx outline(ctx, p.x, p.y, p.w, p.h, p.r, st, seedOf(p.el)) paint(ctx, fill, solid ? { ...st, hatch: false } : st, [p.x, p.y, p.w, p.h]) outline(ctx, p.x, p.y, p.w, p.h, p.r, st, seedOf(p.el)) stroke(ctx, line, width, st) } const painters: Record<string, StagePainter> = { button(p, s) { const outlineOnly = p.el.dataset.look === 'outline', y = p.press * 2 lift({ ...p, y: p.y + y }, s, 4 - p.press * 3) const at = { ...p, y: p.y + y } body(at, s, outlineOnly ? c(s, p.hover ? st.soft : st.paper) : c(s, p.disabled ? st.soft : st.accent), c(s, outlineOnly || st.rough ? st.ink : st.accent), outlineOnly ? 1.5 : 1) if (p.hover && !st.hatch && !outlineOnly) { const g = s.ctx.createRadialGradient(p.x + p.px, p.y + p.py, 0, p.x + p.px, p.y + p.py, p.w * 0.7) g.addColorStop(0, `rgb(255 255 255 / ${0.28 * p.hover})`) g.addColorStop(1, 'rgb(255 255 255 / 0)') outline(s.ctx, at.x, at.y, at.w, at.h, at.r, st, seedOf(p.el)) s.ctx.fillStyle = g s.ctx.fill() } ring(p, s) }, card(p, s) { const live = p.el.matches('a[href], button'), chosen = p.on > 0.5 lift(p, s, 6 + (live ? p.hover * 6 : 0)) body(p, s, c(s, st.paper), c(s, chosen ? st.accent : st.rough ? st.ink : st.soft), chosen ? 3 : 1.5) ring(p, s) }, panel(p, s) { lift(p, s, 14); body(p, s, c(s, st.paper), c(s, st.ink), 1.5, true) }, bar(p, s) { const ctx = s.ctx ctx.beginPath() for (let y = p.y + 3; y < p.y + p.h - 2; y += 4) { ctx.moveTo(p.x + 6, y); ctx.lineTo(p.x + p.w - 6, y) } stroke(ctx, c(s, st.soft), 1, st) }, field(p, s) { const line = p.invalid ? c(s, '--warp-color-critical-solid') : c(s, p.focus > 0.5 ? st.accent : st.soft) body(p, s, c(s, st.paper), line, 1.5 + p.focus, true) if (p.el.tagName === 'SELECT' || p.el.getAttribute('aria-haspopup') === 'listbox') { const ctx = s.ctx, x = p.x + p.w - 18, y = p.y + p.h / 2, d = p.on > 0.5 ? -3 : 3 ctx.beginPath(); ctx.moveTo(x - 5, y - d); ctx.lineTo(x, y + d); ctx.lineTo(x + 5, y - d); stroke(ctx, c(s, st.ink), 1.6, st) } }, // A StageSelect's list, in a stage of its own: a panel on a short lift, and the row under the keys or pointer filled. menu(p, s) { lift(p, s, 6); body(p, s, c(s, st.paper), c(s, st.rough ? st.ink : st.soft), 1.5, true) }, option(p, s) { if (p.hover > 0.5) body(p, s, c(s, st.accent), c(s, st.accent), 1) else if (p.on > 0.5) { const ctx = s.ctx; ctx.beginPath(); ctx.moveTo(p.x + 3, p.y + 8); ctx.lineTo(p.x + 3, p.y + p.h - 8); stroke(ctx, c(s, st.accent), 3, st) } }, check(p, s) { const ctx = s.ctx, radio = (p.el as HTMLInputElement).type === 'radio', round = radio ? p.w / 2 : 5 body({ ...p, r: round }, s, c(s, p.hover ? st.soft : st.paper), c(s, st.rough ? st.ink : p.on > 0.5 ? st.accent : st.soft), 1.6) if (p.on > 0) { if (radio) { ctx.beginPath(); ctx.arc(p.x + p.w / 2, p.y + p.h / 2, (p.w / 4) * p.on, 0, Math.PI * 2); paint(ctx, c(s, st.accent), { ...st, hatch: false }, [p.x, p.y, p.w, p.h]) } else { // The tick draws itself on as `on` eases in. ctx.beginPath(); ctx.moveTo(p.x + p.w * 0.22, p.y + p.h * 0.52); ctx.lineTo(p.x + p.w * 0.42, p.y + p.h * 0.72); ctx.lineTo(p.x + p.w * 0.8, p.y + p.h * 0.28) ctx.setLineDash([40 * p.on, 40]); stroke(ctx, c(s, st.accent), 2.6, st); ctx.setLineDash([]) } } ring({ ...p, r: round }, s) }, switch(p, s) { const ctx = s.ctx, d = p.h - 6, kx = p.x + 3 + (p.w - p.h) * p.on body({ ...p, r: p.h / 2 }, s, p.on > 0.5 ? c(s, st.accent) : c(s, st.soft), c(s, st.rough ? st.ink : st.soft), 1.4) ctx.beginPath(); ctx.arc(kx + d / 2, p.y + 3 + d / 2, d / 2, 0, Math.PI * 2) ctx.fillStyle = c(s, st.paper); ctx.fill(); stroke(ctx, c(s, st.ink), 1.2, st) ring({ ...p, r: p.h / 2 }, s) }, range(p, s) { const ctx = s.ctx, y = p.y + p.h / 2, kx = p.x + 8 + (p.w - 16) * p.value ctx.beginPath(); ctx.moveTo(p.x + 2, y); ctx.lineTo(p.x + p.w - 2, y); stroke(ctx, c(s, st.soft), 4, st) ctx.beginPath(); ctx.moveTo(p.x + 2, y); ctx.lineTo(kx, y); stroke(ctx, c(s, st.accent), 4, st) ctx.beginPath(); ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2); ctx.fillStyle = c(s, st.paper); ctx.fill(); stroke(ctx, c(s, st.accent), 2.5, st) if (p.focus > 0.5) ring({ ...p, x: kx - 10, y: y - 10, w: 20, h: 20, r: 10 }, s) }, meter(p, s) { body({ ...p, r: p.h / 2 }, s, c(s, st.paper), c(s, st.soft), 1.2) const ctx = s.ctx outline(ctx, p.x, p.y, Math.max(p.h, p.w * p.value), p.h, p.h / 2, st, seedOf(p.el) + 5) paint(ctx, c(s, st.accent), st, [p.x, p.y, p.w * p.value, p.h]) }, chip(p, s) { body({ ...p, r: p.h / 2 }, s, c(s, p.on > 0.5 ? st.accent : p.hover ? st.soft : st.paper), c(s, p.on > 0.5 ? st.accent : st.soft), 1.2) ring({ ...p, r: p.h / 2 }, s) }, rule(p, s) { const ctx = s.ctx, y = p.y + p.h / 2, j = rng(seedOf(p.el)) ctx.beginPath(); ctx.moveTo(p.x, y) for (let x = p.x; x <= p.x + p.w; x += 24) ctx.lineTo(x, y + j() * st.rough * 1.5) stroke(ctx, c(s, st.soft), 1.5, st) }, clear(p, s) { const ctx = s.ctx; ctx.beginPath(); ctx.roundRect(p.x - 4, p.y - 4, p.w + 8, p.h + 8, 8); ctx.globalAlpha = 0.86; ctx.fillStyle = c(s, st.paper); ctx.fill() }, type: st.type, art: st.art, } const overlay = (s: Scene) => { const ctx = s.ctx for (const press of s.presses) { ctx.globalAlpha = 1 - press.age ctx.beginPath(); ctx.arc(press.x, press.y, 6 + press.age * 60, 0, Math.PI * 2) stroke(ctx, c(s, st.accent), 2, st) } ctx.globalAlpha = 1 if (s.rail) { ctx.beginPath(); ctx.roundRect(s.rail.x, s.rail.top, s.rail.w, s.rail.len, s.rail.w / 2); ctx.fillStyle = c(s, s.rail.drag ? st.accent : st.soft); ctx.fill() } } const plain = (s: Scene) => { s.ctx.fillStyle = c(s, st.paper); s.ctx.fillRect(0, 0, s.width, s.height) } // A modal's stage covers the page with a wash that fades in each time it opens. const veil = (s: Scene) => { s.ctx.globalAlpha = Math.min(1, s.live ? s.shown / 0.2 : 1) * 0.5; s.ctx.fillStyle = '#000'; s.ctx.fillRect(0, 0, s.width, s.height); s.ctx.globalAlpha = 1 } return { stage: { painters, overlay, background: st.ground }, plain, veil, style: st }}const words = (p: StagePiece, s: Scene, fill: (ctx: CanvasRenderingContext2D) => void, tilt = 0) => { const ctx = s.ctx, j = rng(seedOf(p.el)) fill(ctx) for (const w of s.words(p.el)) { ctx.font = w.font ctx.letterSpacing = `${w.spacing}px` ctx.save(); ctx.translate(w.x, w.y); ctx.rotate(j() * tilt); ctx.fillText(w.text, 0, 0); ctx.restore() }}const TOKENS = { ink: 'var(--stage-ink, var(--warp-color-text))', paper: 'var(--stage-paper, var(--warp-color-surface))', accent: 'var(--stage-accent, var(--warp-color-accent-solid))' }const LOOKS = { flat: lookKit({ ...TOKENS, soft: 'var(--stage-soft, var(--warp-color-border))', onFill: 'var(--warp-color-accent-on-solid)', rough: 0, glow: 0, hatch: false, shadow: 'soft', ground(s) { // Dots that scroll with the page, painted under everything. const ctx = s.ctx, gap = 28, off = (s.scroll?.top ?? 0) % gap ctx.fillStyle = s.color('--warp-color-surface-sunken'); ctx.fillRect(0, 0, s.width, s.height) ctx.fillStyle = s.color('--warp-color-border') for (let y = -off; y < s.height; y += gap) for (let x = gap / 2; x < s.width; x += gap) ctx.fillRect(x, y, 2, 2) }, art(p, s) { const ctx = s.ctx, t = s.time * 0.4 ctx.beginPath(); ctx.roundRect(p.x, p.y, p.w, p.h, p.r); ctx.clip() ctx.fillStyle = s.color('--warp-color-accent-soft'); ctx.fillRect(p.x, p.y, p.w, p.h) for (const [i, tone] of ['--warp-color-accent-solid', '--warp-color-info-solid', '--warp-color-highlight-solid'].entries()) { const cx = p.x + p.w * (0.5 + Math.sin(t + i * 2.1) * 0.3), cy = p.y + p.h * (0.5 + Math.cos(t * 1.3 + i) * 0.3), g = ctx.createRadialGradient(cx, cy, 0, cx, cy, p.w * 0.45) g.addColorStop(0, s.color(tone)); g.addColorStop(1, 'transparent') ctx.globalAlpha = 0.75; ctx.fillStyle = g; ctx.fillRect(p.x, p.y, p.w, p.h) } }, type(p, s) { words(p, s, ctx => { const g = ctx.createLinearGradient(p.x, p.y, p.x + p.w, p.y + p.h); g.addColorStop(0, s.color(TOKENS.accent)); g.addColorStop(1, s.color('--warp-color-info-solid')); ctx.fillStyle = g }) }, }), sketch: lookKit({ ...TOKENS, soft: 'var(--stage-soft, var(--warp-color-text-subtle))', onFill: 'var(--warp-color-text)', rough: 2.2, glow: 0, hatch: true, shadow: 'offset', ground(s) { // Ruled paper: the lines are fixed to the page, so they scroll with it, and a margin rule runs down the side. const ctx = s.ctx, gap = 30, off = (s.scroll?.top ?? 0) % gap ctx.fillStyle = s.color(TOKENS.paper); ctx.fillRect(0, 0, s.width, s.height) ctx.strokeStyle = s.color('--warp-color-info-border'); ctx.lineWidth = 1 ctx.beginPath(); for (let y = gap - off; y < s.height; y += gap) { ctx.moveTo(0, y + 0.5); ctx.lineTo(s.width, y + 0.5) } ctx.stroke() ctx.strokeStyle = s.color('--warp-color-critical-border'); ctx.beginPath(); ctx.moveTo(18.5, 0); ctx.lineTo(18.5, s.height); ctx.stroke() }, art(p, s) { const ctx = s.ctx, j = rng(seedOf(p.el)), cx = p.x + p.w / 2, cy = p.y + p.h / 2 ctx.strokeStyle = s.color(TOKENS.ink); ctx.lineWidth = 1.4 for (let r = 8; r < Math.min(p.w, p.h) / 2; r += 9) { ctx.beginPath(); for (let a = 0; a <= Math.PI * 2.05; a += 0.3) ctx.lineTo(cx + Math.cos(a) * (r + j() * 3), cy + Math.sin(a) * (r + j() * 3)); ctx.stroke() } outline(ctx, p.x, p.y, p.w, p.h, 0, LOOKS.sketch.style, seedOf(p.el)); stroke(ctx, s.color(TOKENS.ink), 1.5, LOOKS.sketch.style) }, type(p, s) { words(p, s, ctx => (ctx.fillStyle = s.color(TOKENS.ink)), 0.04) }, }), neon: lookKit({ // Its own colours, a pair for each theme: an app's --stage-* still wins over them. ink: 'var(--stage-ink, light-dark(#3a1060, #f4ecff))', paper: 'var(--stage-paper, light-dark(#fff2fa, #120a24))', accent: 'var(--stage-accent, light-dark(#d81b78, #ff3ea5))', soft: 'var(--stage-soft, light-dark(#e3b8ef, #4a3a7a))', onFill: '#fff', rough: 0, glow: 14, hatch: false, shadow: 'none', ground(s) { // A horizon and a floor grid that rolls toward you as the page scrolls and time passes. const ctx = s.ctx, w = s.width, h = s.height, hz = h * 0.42, sky = ctx.createLinearGradient(0, 0, 0, h) sky.addColorStop(0, s.color('light-dark(#fff2fa, #07021a)')); sky.addColorStop(0.42, s.color('light-dark(#ffd1ec, #2a0b4a)')); sky.addColorStop(1, s.color('light-dark(#fff2fa, #07021a)')) ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h) ctx.strokeStyle = ctx.shadowColor = s.color('light-dark(#c86bff, #7b2cff)'); ctx.lineWidth = 1; ctx.shadowBlur = 8 const roll = ((s.scroll?.top ?? 0) / 40 + s.time * 0.6) % 1 ctx.beginPath() for (let i = 0; i < 14; i++) { const z = (i + roll) / 14, y = hz + (h - hz) * z * z; ctx.moveTo(0, y); ctx.lineTo(w, y) } for (let i = -12; i <= 12; i++) { ctx.moveTo(w / 2 + i * 12, hz); ctx.lineTo(w / 2 + i * w * 0.18, h) } ctx.stroke(); ctx.shadowBlur = 0 }, art(p, s) { // A striped sun setting into the floor. const ctx = s.ctx, cx = p.x + p.w / 2, cy = p.y + p.h * 0.62, r = Math.min(p.w, p.h) * 0.38, g = ctx.createLinearGradient(0, cy - r, 0, cy + r) ctx.beginPath(); ctx.roundRect(p.x, p.y, p.w, p.h, p.r); ctx.clip() g.addColorStop(0, '#ffd23f'); g.addColorStop(1, s.color(LOOKS.neon.style.accent)) ctx.shadowColor = s.color(LOOKS.neon.style.accent); ctx.shadowBlur = 30; ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r * (1 + p.hover * 0.06), 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0 ctx.fillStyle = s.color(LOOKS.neon.style.paper) for (let i = 0; i < 6; i++) { const y = cy + r * (0.1 + i * 0.16) - ((s.time * 6) % (r * 0.16)); ctx.fillRect(p.x, y, p.w, 2 + i * 1.2) } }, type(p, s) { s.ctx.shadowColor = s.color(LOOKS.neon.style.accent); s.ctx.shadowBlur = 18 words(p, s, ctx => { const g = ctx.createLinearGradient(p.x, p.y, p.x, p.y + p.h); g.addColorStop(0, s.color('light-dark(#0aa6c9, #28e7ff)')); g.addColorStop(1, s.color(LOOKS.neon.style.accent)); ctx.fillStyle = g }) }, }),} satisfies Record<LookName, ReturnType<typeof lookKit>>const lookStyle = (name: LookName, rest = '') => `${rest};--stage-on-fill:${LOOKS[name].style.onFill}`const FLAT_CHOOSE = lookStyle('flat', 'padding:28px;min-block-size:300px')const Choose = () => ( <div data-skin="card" style="display:grid;gap:14px;inline-size:min(340px, 100%)"> <label data-part="label">Region<StageSelect name="region" options={['eu-west', 'us-east', 'ap-south', 'sa-east']} /></label> <label data-part="label">Plan<StageSelect name="plan" value="team" options={[{ value: 'hobby', label: 'Hobby' }, { value: 'team', label: 'Team' }, { value: 'scale', label: 'Scale (talk to us)', disabled: true }]} /></label> </div>)Sketch
Its paper is solid, so the page under it stays hidden.
import { CanvasStage, StageSelect, Text } from '@weft-js/warp'import type { Scene, StagePainter, StagePiece } from '@weft-js/warp'export function Sketch() { return ( <CanvasStage {...LOOKS.sketch.stage} style={SKETCH_CHOOSE}><Choose /></CanvasStage> )}const wobble = (i: number) => Math.sin(i * 12.9898) * 43758.5453 % 1const min = 60_000type LookName = 'flat' | 'sketch' | 'neon'type Style = { ink: string paper: string accent: string soft: string /** Text on a filled button or chip. */ onFill: string /** Pixels of wobble: 0 draws clean lines. */ rough: number /** Glow, in pixels of blur. */ glow: number /** Fills as pencil hatching instead of solid. */ hatch: boolean shadow: 'soft' | 'offset' | 'none' ground: (s: Scene) => void art: (p: StagePiece, s: Scene) => void type: (p: StagePiece, s: Scene) => void}const seeds = new WeakMap<Element, number>()let seedNext = 1const seedOf = (el: Element) => { let v = seeds.get(el); if (v === undefined) seeds.set(el, (v = seedNext++ * 7919)); return v }const rng = (seed: number) => () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647 - 0.5 }function outline(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number, st: Style, seed: number) { ctx.beginPath() if (!st.rough) return void ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2)) const j = rng(seed), k = st.rough, pts: [number, number][] = [] const edge = (ax: number, ay: number, bx: number, by: number) => { const n = Math.max(2, Math.round(Math.hypot(bx - ax, by - ay) / 28)); for (let i = 0; i < n; i++) pts.push([ax + ((bx - ax) * i) / n + j() * k, ay + ((by - ay) * i) / n + j() * k]) } edge(x, y, x + w, y); edge(x + w, y, x + w, y + h); edge(x + w, y + h, x, y + h); edge(x, y + h, x, y) ctx.moveTo(pts[0]![0], pts[0]![1]) for (let i = 1; i <= pts.length; i++) { const [px, py] = pts[i % pts.length]!; ctx.lineTo(px, py) } ctx.lineTo(pts[0]![0] + j() * k, pts[0]![1] + j() * k)}function stroke(ctx: CanvasRenderingContext2D, color: string, width: number, st: Style) { ctx.strokeStyle = color ctx.lineWidth = width ctx.lineJoin = ctx.lineCap = 'round' if (st.glow) { ctx.shadowColor = color; ctx.shadowBlur = st.glow } ctx.stroke() ctx.shadowBlur = 0}function paint(ctx: CanvasRenderingContext2D, color: string, st: Style, box: [number, number, number, number], density = 1) { if (!st.hatch) { ctx.fillStyle = color; ctx.fill(); return } // Pencil hatching: the shape clips a run of diagonal strokes. const [x, y, w, h] = box, gap = 7 / density ctx.save() ctx.clip() ctx.beginPath() for (let d = -h; d < w; d += gap) { ctx.moveTo(x + d, y + h); ctx.lineTo(x + d + h, y) } ctx.strokeStyle = color ctx.lineWidth = 1.6 ctx.globalAlpha = 0.55 ctx.stroke() ctx.restore()}function lookKit(st: Style) { const c = (s: Scene, name: string) => s.color(name) const ring = (p: StagePiece, s: Scene) => { if (p.focus < 0.5) return const ctx = s.ctx ctx.setLineDash([6, 5]) ctx.lineDashOffset = -s.time * 24 outline(ctx, p.x - 4, p.y - 4, p.w + 8, p.h + 8, p.r + 4, st, seedOf(p.el) + 3) stroke(ctx, c(s, st.accent), 2, st) ctx.setLineDash([]) } const lift = (p: StagePiece, s: Scene, depth: number) => { const ctx = s.ctx if (st.shadow === 'soft') { ctx.shadowColor = 'rgb(0 0 0 / 0.2)'; ctx.shadowBlur = depth * 3; ctx.shadowOffsetY = depth; outline(ctx, p.x, p.y, p.w, p.h, p.r, st, seedOf(p.el)); ctx.fillStyle = c(s, st.paper); ctx.fill(); ctx.shadowColor = 'transparent' } if (st.shadow === 'offset') { outline(ctx, p.x + depth, p.y + depth, p.w, p.h, p.r, st, seedOf(p.el) + 1); paint(ctx, c(s, st.soft), st, [p.x + depth, p.y + depth, p.w, p.h], 0.7) } } // `solid` keeps a surface's paper whole: a panel or a list floats over the page, and a field's words need clean ground. const body = (p: StagePiece, s: Scene, fill: string, line: string, width = 1.5, solid = false) => { const ctx = s.ctx outline(ctx, p.x, p.y, p.w, p.h, p.r, st, seedOf(p.el)) paint(ctx, fill, solid ? { ...st, hatch: false } : st, [p.x, p.y, p.w, p.h]) outline(ctx, p.x, p.y, p.w, p.h, p.r, st, seedOf(p.el)) stroke(ctx, line, width, st) } const painters: Record<string, StagePainter> = { button(p, s) { const outlineOnly = p.el.dataset.look === 'outline', y = p.press * 2 lift({ ...p, y: p.y + y }, s, 4 - p.press * 3) const at = { ...p, y: p.y + y } body(at, s, outlineOnly ? c(s, p.hover ? st.soft : st.paper) : c(s, p.disabled ? st.soft : st.accent), c(s, outlineOnly || st.rough ? st.ink : st.accent), outlineOnly ? 1.5 : 1) if (p.hover && !st.hatch && !outlineOnly) { const g = s.ctx.createRadialGradient(p.x + p.px, p.y + p.py, 0, p.x + p.px, p.y + p.py, p.w * 0.7) g.addColorStop(0, `rgb(255 255 255 / ${0.28 * p.hover})`) g.addColorStop(1, 'rgb(255 255 255 / 0)') outline(s.ctx, at.x, at.y, at.w, at.h, at.r, st, seedOf(p.el)) s.ctx.fillStyle = g s.ctx.fill() } ring(p, s) }, card(p, s) { const live = p.el.matches('a[href], button'), chosen = p.on > 0.5 lift(p, s, 6 + (live ? p.hover * 6 : 0)) body(p, s, c(s, st.paper), c(s, chosen ? st.accent : st.rough ? st.ink : st.soft), chosen ? 3 : 1.5) ring(p, s) }, panel(p, s) { lift(p, s, 14); body(p, s, c(s, st.paper), c(s, st.ink), 1.5, true) }, bar(p, s) { const ctx = s.ctx ctx.beginPath() for (let y = p.y + 3; y < p.y + p.h - 2; y += 4) { ctx.moveTo(p.x + 6, y); ctx.lineTo(p.x + p.w - 6, y) } stroke(ctx, c(s, st.soft), 1, st) }, field(p, s) { const line = p.invalid ? c(s, '--warp-color-critical-solid') : c(s, p.focus > 0.5 ? st.accent : st.soft) body(p, s, c(s, st.paper), line, 1.5 + p.focus, true) if (p.el.tagName === 'SELECT' || p.el.getAttribute('aria-haspopup') === 'listbox') { const ctx = s.ctx, x = p.x + p.w - 18, y = p.y + p.h / 2, d = p.on > 0.5 ? -3 : 3 ctx.beginPath(); ctx.moveTo(x - 5, y - d); ctx.lineTo(x, y + d); ctx.lineTo(x + 5, y - d); stroke(ctx, c(s, st.ink), 1.6, st) } }, // A StageSelect's list, in a stage of its own: a panel on a short lift, and the row under the keys or pointer filled. menu(p, s) { lift(p, s, 6); body(p, s, c(s, st.paper), c(s, st.rough ? st.ink : st.soft), 1.5, true) }, option(p, s) { if (p.hover > 0.5) body(p, s, c(s, st.accent), c(s, st.accent), 1) else if (p.on > 0.5) { const ctx = s.ctx; ctx.beginPath(); ctx.moveTo(p.x + 3, p.y + 8); ctx.lineTo(p.x + 3, p.y + p.h - 8); stroke(ctx, c(s, st.accent), 3, st) } }, check(p, s) { const ctx = s.ctx, radio = (p.el as HTMLInputElement).type === 'radio', round = radio ? p.w / 2 : 5 body({ ...p, r: round }, s, c(s, p.hover ? st.soft : st.paper), c(s, st.rough ? st.ink : p.on > 0.5 ? st.accent : st.soft), 1.6) if (p.on > 0) { if (radio) { ctx.beginPath(); ctx.arc(p.x + p.w / 2, p.y + p.h / 2, (p.w / 4) * p.on, 0, Math.PI * 2); paint(ctx, c(s, st.accent), { ...st, hatch: false }, [p.x, p.y, p.w, p.h]) } else { // The tick draws itself on as `on` eases in. ctx.beginPath(); ctx.moveTo(p.x + p.w * 0.22, p.y + p.h * 0.52); ctx.lineTo(p.x + p.w * 0.42, p.y + p.h * 0.72); ctx.lineTo(p.x + p.w * 0.8, p.y + p.h * 0.28) ctx.setLineDash([40 * p.on, 40]); stroke(ctx, c(s, st.accent), 2.6, st); ctx.setLineDash([]) } } ring({ ...p, r: round }, s) }, switch(p, s) { const ctx = s.ctx, d = p.h - 6, kx = p.x + 3 + (p.w - p.h) * p.on body({ ...p, r: p.h / 2 }, s, p.on > 0.5 ? c(s, st.accent) : c(s, st.soft), c(s, st.rough ? st.ink : st.soft), 1.4) ctx.beginPath(); ctx.arc(kx + d / 2, p.y + 3 + d / 2, d / 2, 0, Math.PI * 2) ctx.fillStyle = c(s, st.paper); ctx.fill(); stroke(ctx, c(s, st.ink), 1.2, st) ring({ ...p, r: p.h / 2 }, s) }, range(p, s) { const ctx = s.ctx, y = p.y + p.h / 2, kx = p.x + 8 + (p.w - 16) * p.value ctx.beginPath(); ctx.moveTo(p.x + 2, y); ctx.lineTo(p.x + p.w - 2, y); stroke(ctx, c(s, st.soft), 4, st) ctx.beginPath(); ctx.moveTo(p.x + 2, y); ctx.lineTo(kx, y); stroke(ctx, c(s, st.accent), 4, st) ctx.beginPath(); ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2); ctx.fillStyle = c(s, st.paper); ctx.fill(); stroke(ctx, c(s, st.accent), 2.5, st) if (p.focus > 0.5) ring({ ...p, x: kx - 10, y: y - 10, w: 20, h: 20, r: 10 }, s) }, meter(p, s) { body({ ...p, r: p.h / 2 }, s, c(s, st.paper), c(s, st.soft), 1.2) const ctx = s.ctx outline(ctx, p.x, p.y, Math.max(p.h, p.w * p.value), p.h, p.h / 2, st, seedOf(p.el) + 5) paint(ctx, c(s, st.accent), st, [p.x, p.y, p.w * p.value, p.h]) }, chip(p, s) { body({ ...p, r: p.h / 2 }, s, c(s, p.on > 0.5 ? st.accent : p.hover ? st.soft : st.paper), c(s, p.on > 0.5 ? st.accent : st.soft), 1.2) ring({ ...p, r: p.h / 2 }, s) }, rule(p, s) { const ctx = s.ctx, y = p.y + p.h / 2, j = rng(seedOf(p.el)) ctx.beginPath(); ctx.moveTo(p.x, y) for (let x = p.x; x <= p.x + p.w; x += 24) ctx.lineTo(x, y + j() * st.rough * 1.5) stroke(ctx, c(s, st.soft), 1.5, st) }, clear(p, s) { const ctx = s.ctx; ctx.beginPath(); ctx.roundRect(p.x - 4, p.y - 4, p.w + 8, p.h + 8, 8); ctx.globalAlpha = 0.86; ctx.fillStyle = c(s, st.paper); ctx.fill() }, type: st.type, art: st.art, } const overlay = (s: Scene) => { const ctx = s.ctx for (const press of s.presses) { ctx.globalAlpha = 1 - press.age ctx.beginPath(); ctx.arc(press.x, press.y, 6 + press.age * 60, 0, Math.PI * 2) stroke(ctx, c(s, st.accent), 2, st) } ctx.globalAlpha = 1 if (s.rail) { ctx.beginPath(); ctx.roundRect(s.rail.x, s.rail.top, s.rail.w, s.rail.len, s.rail.w / 2); ctx.fillStyle = c(s, s.rail.drag ? st.accent : st.soft); ctx.fill() } } const plain = (s: Scene) => { s.ctx.fillStyle = c(s, st.paper); s.ctx.fillRect(0, 0, s.width, s.height) } // A modal's stage covers the page with a wash that fades in each time it opens. const veil = (s: Scene) => { s.ctx.globalAlpha = Math.min(1, s.live ? s.shown / 0.2 : 1) * 0.5; s.ctx.fillStyle = '#000'; s.ctx.fillRect(0, 0, s.width, s.height); s.ctx.globalAlpha = 1 } return { stage: { painters, overlay, background: st.ground }, plain, veil, style: st }}const words = (p: StagePiece, s: Scene, fill: (ctx: CanvasRenderingContext2D) => void, tilt = 0) => { const ctx = s.ctx, j = rng(seedOf(p.el)) fill(ctx) for (const w of s.words(p.el)) { ctx.font = w.font ctx.letterSpacing = `${w.spacing}px` ctx.save(); ctx.translate(w.x, w.y); ctx.rotate(j() * tilt); ctx.fillText(w.text, 0, 0); ctx.restore() }}const TOKENS = { ink: 'var(--stage-ink, var(--warp-color-text))', paper: 'var(--stage-paper, var(--warp-color-surface))', accent: 'var(--stage-accent, var(--warp-color-accent-solid))' }const LOOKS = { flat: lookKit({ ...TOKENS, soft: 'var(--stage-soft, var(--warp-color-border))', onFill: 'var(--warp-color-accent-on-solid)', rough: 0, glow: 0, hatch: false, shadow: 'soft', ground(s) { // Dots that scroll with the page, painted under everything. const ctx = s.ctx, gap = 28, off = (s.scroll?.top ?? 0) % gap ctx.fillStyle = s.color('--warp-color-surface-sunken'); ctx.fillRect(0, 0, s.width, s.height) ctx.fillStyle = s.color('--warp-color-border') for (let y = -off; y < s.height; y += gap) for (let x = gap / 2; x < s.width; x += gap) ctx.fillRect(x, y, 2, 2) }, art(p, s) { const ctx = s.ctx, t = s.time * 0.4 ctx.beginPath(); ctx.roundRect(p.x, p.y, p.w, p.h, p.r); ctx.clip() ctx.fillStyle = s.color('--warp-color-accent-soft'); ctx.fillRect(p.x, p.y, p.w, p.h) for (const [i, tone] of ['--warp-color-accent-solid', '--warp-color-info-solid', '--warp-color-highlight-solid'].entries()) { const cx = p.x + p.w * (0.5 + Math.sin(t + i * 2.1) * 0.3), cy = p.y + p.h * (0.5 + Math.cos(t * 1.3 + i) * 0.3), g = ctx.createRadialGradient(cx, cy, 0, cx, cy, p.w * 0.45) g.addColorStop(0, s.color(tone)); g.addColorStop(1, 'transparent') ctx.globalAlpha = 0.75; ctx.fillStyle = g; ctx.fillRect(p.x, p.y, p.w, p.h) } }, type(p, s) { words(p, s, ctx => { const g = ctx.createLinearGradient(p.x, p.y, p.x + p.w, p.y + p.h); g.addColorStop(0, s.color(TOKENS.accent)); g.addColorStop(1, s.color('--warp-color-info-solid')); ctx.fillStyle = g }) }, }), sketch: lookKit({ ...TOKENS, soft: 'var(--stage-soft, var(--warp-color-text-subtle))', onFill: 'var(--warp-color-text)', rough: 2.2, glow: 0, hatch: true, shadow: 'offset', ground(s) { // Ruled paper: the lines are fixed to the page, so they scroll with it, and a margin rule runs down the side. const ctx = s.ctx, gap = 30, off = (s.scroll?.top ?? 0) % gap ctx.fillStyle = s.color(TOKENS.paper); ctx.fillRect(0, 0, s.width, s.height) ctx.strokeStyle = s.color('--warp-color-info-border'); ctx.lineWidth = 1 ctx.beginPath(); for (let y = gap - off; y < s.height; y += gap) { ctx.moveTo(0, y + 0.5); ctx.lineTo(s.width, y + 0.5) } ctx.stroke() ctx.strokeStyle = s.color('--warp-color-critical-border'); ctx.beginPath(); ctx.moveTo(18.5, 0); ctx.lineTo(18.5, s.height); ctx.stroke() }, art(p, s) { const ctx = s.ctx, j = rng(seedOf(p.el)), cx = p.x + p.w / 2, cy = p.y + p.h / 2 ctx.strokeStyle = s.color(TOKENS.ink); ctx.lineWidth = 1.4 for (let r = 8; r < Math.min(p.w, p.h) / 2; r += 9) { ctx.beginPath(); for (let a = 0; a <= Math.PI * 2.05; a += 0.3) ctx.lineTo(cx + Math.cos(a) * (r + j() * 3), cy + Math.sin(a) * (r + j() * 3)); ctx.stroke() } outline(ctx, p.x, p.y, p.w, p.h, 0, LOOKS.sketch.style, seedOf(p.el)); stroke(ctx, s.color(TOKENS.ink), 1.5, LOOKS.sketch.style) }, type(p, s) { words(p, s, ctx => (ctx.fillStyle = s.color(TOKENS.ink)), 0.04) }, }), neon: lookKit({ // Its own colours, a pair for each theme: an app's --stage-* still wins over them. ink: 'var(--stage-ink, light-dark(#3a1060, #f4ecff))', paper: 'var(--stage-paper, light-dark(#fff2fa, #120a24))', accent: 'var(--stage-accent, light-dark(#d81b78, #ff3ea5))', soft: 'var(--stage-soft, light-dark(#e3b8ef, #4a3a7a))', onFill: '#fff', rough: 0, glow: 14, hatch: false, shadow: 'none', ground(s) { // A horizon and a floor grid that rolls toward you as the page scrolls and time passes. const ctx = s.ctx, w = s.width, h = s.height, hz = h * 0.42, sky = ctx.createLinearGradient(0, 0, 0, h) sky.addColorStop(0, s.color('light-dark(#fff2fa, #07021a)')); sky.addColorStop(0.42, s.color('light-dark(#ffd1ec, #2a0b4a)')); sky.addColorStop(1, s.color('light-dark(#fff2fa, #07021a)')) ctx.fillStyle = sky; ctx.fillRect(0, 0, w, h) ctx.strokeStyle = ctx.shadowColor = s.color('light-dark(#c86bff, #7b2cff)'); ctx.lineWidth = 1; ctx.shadowBlur = 8 const roll = ((s.scroll?.top ?? 0) / 40 + s.time * 0.6) % 1 ctx.beginPath() for (let i = 0; i < 14; i++) { const z = (i + roll) / 14, y = hz + (h - hz) * z * z; ctx.moveTo(0, y); ctx.lineTo(w, y) } for (let i = -12; i <= 12; i++) { ctx.moveTo(w / 2 + i * 12, hz); ctx.lineTo(w / 2 + i * w * 0.18, h) } ctx.stroke(); ctx.shadowBlur = 0 }, art(p, s) { // A striped sun setting into the floor. const ctx = s.ctx, cx = p.x + p.w / 2, cy = p.y + p.h * 0.62, r = Math.min(p.w, p.h) * 0.38, g = ctx.createLinearGradient(0, cy - r, 0, cy + r) ctx.beginPath(); ctx.roundRect(p.x, p.y, p.w, p.h, p.r); ctx.clip() g.addColorStop(0, '#ffd23f'); g.addColorStop(1, s.color(LOOKS.neon.style.accent)) ctx.shadowColor = s.color(LOOKS.neon.style.accent); ctx.shadowBlur = 30; ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r * (1 + p.hover * 0.06), 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0 ctx.fillStyle = s.color(LOOKS.neon.style.paper) for (let i = 0; i < 6; i++) { const y = cy + r * (0.1 + i * 0.16) - ((s.time * 6) % (r * 0.16)); ctx.fillRect(p.x, y, p.w, 2 + i * 1.2) } }, type(p, s) { s.ctx.shadowColor = s.color(LOOKS.neon.style.accent); s.ctx.shadowBlur = 18 words(p, s, ctx => { const g = ctx.createLinearGradient(p.x, p.y, p.x, p.y + p.h); g.addColorStop(0, s.color('light-dark(#0aa6c9, #28e7ff)')); g.addColorStop(1, s.color(LOOKS.neon.style.accent)); ctx.fillStyle = g }) }, }),} satisfies Record<LookName, ReturnType<typeof lookKit>>const lookStyle = (name: LookName, rest = '') => `${rest};--stage-on-fill:${LOOKS[name].style.onFill}`const SKETCH_CHOOSE = lookStyle('sketch', 'padding:28px 28px 28px 40px;min-block-size:300px')const Choose = () => ( <div data-skin="card" style="display:grid;gap:14px;inline-size:min(340px, 100%)"> <label data-part="label">Region<StageSelect name="region" options={['eu-west', 'us-east', 'ap-south', 'sa-east']} /></label> <label data-part="label">Plan<StageSelect name="plan" value="team" options={[{ value: 'hobby', label: 'Hobby' }, { value: 'team', label: 'Team' }, { value: 'scale', label: 'Scale (talk to us)', disabled: true }]} /></label> </div>)Styling
The trigger is a field skin and gets a chevron that turns over while the list is open. The list is a menu skin and its rows are option skins: piece.hover is the row under the pointer or the keys, and piece.on the chosen one. Size them with --stage-pad-menu, --stage-pad-option and --stage-menu-block, the list's greatest height.
When to use
Use it for
- A select on a CanvasStage or DitherStage, where the native list would break the look
Reach for something else
- Select, when the page isn't on a stage
Accessibility
- An APG select-only combobox: focus stays on the trigger,
aria-activedescendantnames the active row, and arrows, Home, End, Page keys and typeahead move through it. Enter, Space or Tab picks; Escape closes. - Wrap it in a
<label>to name it.nameputs the value in a form through a hidden input.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
options | StageOption[] | — | Strings, or { value, label, disabled }. |
value | string | — | The value it starts on. The first option when left out. |
name | string | — | Puts the value in a form, through a hidden input. |
disabled | boolean | false | Turns it off, and takes it out of the tab order. |
| Event | Type | Default | Description |
|---|---|---|---|
onChange | ((value: string) => void) | — | Hears each value picked. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="stage"] [data-part="select"]
selectfloatpaint