StageSelect
A select a stage draws, list and all, in whatever look the stage around it has.
SourcePackage @kerf-js/veneer
Preview
import { DitherStage, StageSelect } from '@kerf-js/veneer'export function Dither() { return ( <DitherStage ground={0.3} style="padding:28px;min-block-size:300px" > <Choose /> </DitherStage> )}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>)Installation
npm install @kerf-js/veneerUsage
import { StageSelect } from '@kerf-js/veneer'<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 } from '@kerf-js/veneer'import type { Scene, StagePainter, StagePiece} from '@kerf-js/veneer'export function Flat() { return ( <CanvasStage {...LOOKS.flat.stage} style={FLAT_CHOOSE} > <Choose /> </CanvasStage> )}type 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}type Pt = [number, number]type Box = { x: number y: number w: number h: number r: number seed: number}function outline( ctx: CanvasRenderingContext2D, box: Box, st: Style,) { const { x, y, w, h, r, seed } = box ctx.beginPath() if (!st.rough) { ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2)) return } const j = rng(seed), k = st.rough, pts: Pt[] = [] const edge = (a: Pt, b: Pt) => { const n = Math.max( 2, Math.round( Math.hypot(b[0] - a[0], b[1] - a[1]) / 28, ), ) for (let i = 0; i < n; i++) pts.push([ a[0] + ((b[0] - a[0]) * i) / n + j() * k, a[1] + ((b[1] - a[1]) * 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, st: Style, pen: { color: string; width: number },) { const { color, width } = pen 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, st: Style, fill: { color: string box: [number, number, number, number] density?: number },) { const { color, box, density = 1 } = fill 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()}type PaintColor = ( s: Scene, name: string,) => stringtype PaintRing = (p: StagePiece, s: Scene) => voidtype PaintLift = ( p: StagePiece, s: Scene, depth: number,) => voidtype PaintBody = ( p: StagePiece, s: Scene, look: { fill: string line: string width?: number solid?: boolean },) => voidtype Hands = { c: PaintColor ring: PaintRing lift: PaintLift body: PaintBody}function surfacePainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, lift, body } = hands return { 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, { fill: outlineOnly ? c(s, p.hover ? st.soft : st.paper) : c(s, p.disabled ? st.soft : st.accent), line: c( s, outlineOnly || st.rough ? st.ink : st.accent, ), width: 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, seed: seedOf(p.el) }, st) 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, { fill: c(s, st.paper), line: c( s, chosen ? st.accent : st.rough ? st.ink : st.soft, ), width: chosen ? 3 : 1.5, }) ring(p, s) }, panel(p, s) { lift(p, s, 14) body(p, s, { fill: c(s, st.paper), line: c(s, st.ink), width: 1.5, solid: true, }) }, }}function fieldPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, lift, body } = hands return { 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, st, { color: c(s, st.soft), width: 1 }) }, field(p, s) { const line = p.invalid ? c(s, '--veneer-color-critical-solid') : c(s, p.focus > 0.5 ? st.accent : st.soft) body(p, s, { fill: c(s, st.paper), line, width: 1.5 + p.focus, solid: 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, st, { color: c(s, st.ink), width: 1.6, }) } }, // 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, { fill: c(s, st.paper), line: c(s, st.rough ? st.ink : st.soft), width: 1.5, solid: true, }) }, option(p, s) { if (p.hover > 0.5) body(p, s, { fill: c(s, st.accent), line: c(s, st.accent), width: 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, st, { color: c(s, st.accent), width: 3, }) } }, }}function checkPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { 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, { fill: c(s, p.hover ? st.soft : st.paper), line: c( s, st.rough ? st.ink : p.on > 0.5 ? st.accent : st.soft, ), width: 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, { ...st, hatch: false }, { color: c(s, st.accent), box: [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, st, { color: c(s, st.accent), width: 2.6, }) ctx.setLineDash([]) } } ring({ ...p, r: round }, s) }, }}function switchPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { 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, { fill: p.on > 0.5 ? c(s, st.accent) : c(s, st.soft), line: c(s, st.rough ? st.ink : st.soft), width: 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, st, { color: c(s, st.ink), width: 1.2 }) ring({ ...p, r: p.h / 2 }, s) }, }}function rangeMeterPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { 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, st, { color: c(s, st.soft), width: 4 }) ctx.beginPath() ctx.moveTo(p.x + 2, y) ctx.lineTo(kx, y) stroke(ctx, st, { color: c(s, st.accent), width: 4, }) ctx.beginPath() ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2) ctx.fillStyle = c(s, st.paper) ctx.fill() stroke(ctx, st, { color: c(s, st.accent), width: 2.5, }) 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, { fill: c(s, st.paper), line: c(s, st.soft), width: 1.2, }) const ctx = s.ctx outline( ctx, { ...p, w: Math.max(p.h, p.w * p.value), r: p.h / 2, seed: seedOf(p.el) + 5, }, st, ) paint(ctx, st, { color: c(s, st.accent), box: [p.x, p.y, p.w * p.value, p.h], }) }, }}function chipRulePainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { chip(p, s) { body({ ...p, r: p.h / 2 }, s, { fill: c( s, p.on > 0.5 ? st.accent : p.hover ? st.soft : st.paper, ), line: c(s, p.on > 0.5 ? st.accent : st.soft), width: 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, st, { color: c(s, st.soft), width: 1.5, }) }, }}function detailPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c } = hands return { 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, }}function makeFocusRing(st: Style, c: PaintColor) { return (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, { x: p.x - 4, y: p.y - 4, w: p.w + 8, h: p.h + 8, r: p.r + 4, seed: seedOf(p.el) + 3, }, st, ) stroke(ctx, st, { color: c(s, st.accent), width: 2 }) ctx.setLineDash([]) }}function makeSurfaceLift(st: Style, c: PaintColor) { return (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, seed: seedOf(p.el) }, st) ctx.fillStyle = c(s, st.paper) ctx.fill() ctx.shadowColor = 'transparent' } if (st.shadow === 'offset') { outline( ctx, { ...p, x: p.x + depth, y: p.y + depth, seed: seedOf(p.el) + 1, }, st, ) paint(ctx, st, { color: c(s, st.soft), box: [p.x + depth, p.y + depth, p.w, p.h], density: 0.7, }) } }}type BodyLook = Parameters<PaintBody>[2]function makeSurfaceBody(st: Style) { return ( p: StagePiece, s: Scene, { fill, line, width = 1.5, solid = false }: BodyLook, ) => { const ctx = s.ctx outline(ctx, { ...p, seed: seedOf(p.el) }, st) paint(ctx, solid ? { ...st, hatch: false } : st, { color: fill, box: [p.x, p.y, p.w, p.h], }) outline(ctx, { ...p, seed: seedOf(p.el) }, st) stroke(ctx, st, { color: line, width }) }}function makeOverlay(st: Style, c: PaintColor) { return (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, st, { color: c(s, st.accent), width: 2, }) } 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() } }}function makePlain(st: Style, c: PaintColor) { return (s: Scene) => { s.ctx.fillStyle = c(s, st.paper) s.ctx.fillRect(0, 0, s.width, s.height) }}function makeVeil() { return (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 }}function lookKit(st: Style) { const c = (s: Scene, name: string) => s.color(name) const ring = makeFocusRing(st, c) const lift = makeSurfaceLift(st, c) // `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 = makeSurfaceBody(st) const hands = { c, ring, lift, body } const painters: Record<string, StagePainter> = { ...surfacePainters(st, hands), ...fieldPainters(st, hands), ...checkPainters(st, hands), ...switchPainters(st, hands), ...rangeMeterPainters(st, hands), ...chipRulePainters(st, hands), ...detailPainters(st, hands), } const overlay = makeOverlay(st, c) const plain = makePlain(st, c) // A modal's stage covers the page with a wash that // fades in each time it opens. const veil = makeVeil() return { stage: { painters, overlay, background: st.ground }, plain, veil, style: st, }}type WordInk = { fill: (ctx: CanvasRenderingContext2D) => void tilt?: number}const words = ( p: StagePiece, s: Scene, { fill, tilt = 0 }: WordInk,) => { 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(--veneer-color-text))', paper: 'var(--stage-paper, var(--veneer-color-surface))', accent: 'var(--stage-accent, ' + 'var(--veneer-color-accent-solid))',}const LOOKS = { flat: lookKit({ ...TOKENS, soft: 'var(--stage-soft, var(--veneer-color-border))', onFill: 'var(--veneer-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( '--veneer-color-surface-sunken', ) ctx.fillRect(0, 0, s.width, s.height) ctx.fillStyle = s.color('--veneer-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( '--veneer-color-accent-soft', ) ctx.fillRect(p.x, p.y, p.w, p.h) for (const [i, tone] of [ '--veneer-color-accent-solid', '--veneer-color-info-solid', '--veneer-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, { fill: (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('--veneer-color-info-solid'), ) ctx.fillStyle = g }, }) }, }), sketch: lookKit({ ...TOKENS, soft: 'var(--stage-soft, ' + 'var(--veneer-color-text-subtle))', onFill: 'var(--veneer-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( '--veneer-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( '--veneer-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, r: 0, seed: seedOf(p.el) }, LOOKS.sketch.style, ) stroke(ctx, LOOKS.sketch.style, { color: s.color(TOKENS.ink), width: 1.5, }) }, type(p, s) { words(p, s, { fill: (ctx) => (ctx.fillStyle = s.color(TOKENS.ink)), tilt: 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, { fill: (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 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>)const FLAT_CHOOSE = lookStyle( 'flat', 'padding:28px;min-block-size:300px',)Sketch
Its paper is solid, so the page under it stays hidden.
import { CanvasStage, StageSelect } from '@kerf-js/veneer'import type { Scene, StagePainter, StagePiece} from '@kerf-js/veneer'export function Sketch() { return ( <CanvasStage {...LOOKS.sketch.stage} style={SKETCH_CHOOSE} > <Choose /> </CanvasStage> )}type 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}type Pt = [number, number]type Box = { x: number y: number w: number h: number r: number seed: number}function outline( ctx: CanvasRenderingContext2D, box: Box, st: Style,) { const { x, y, w, h, r, seed } = box ctx.beginPath() if (!st.rough) { ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2)) return } const j = rng(seed), k = st.rough, pts: Pt[] = [] const edge = (a: Pt, b: Pt) => { const n = Math.max( 2, Math.round( Math.hypot(b[0] - a[0], b[1] - a[1]) / 28, ), ) for (let i = 0; i < n; i++) pts.push([ a[0] + ((b[0] - a[0]) * i) / n + j() * k, a[1] + ((b[1] - a[1]) * 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, st: Style, pen: { color: string; width: number },) { const { color, width } = pen 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, st: Style, fill: { color: string box: [number, number, number, number] density?: number },) { const { color, box, density = 1 } = fill 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()}type PaintColor = ( s: Scene, name: string,) => stringtype PaintRing = (p: StagePiece, s: Scene) => voidtype PaintLift = ( p: StagePiece, s: Scene, depth: number,) => voidtype PaintBody = ( p: StagePiece, s: Scene, look: { fill: string line: string width?: number solid?: boolean },) => voidtype Hands = { c: PaintColor ring: PaintRing lift: PaintLift body: PaintBody}function surfacePainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, lift, body } = hands return { 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, { fill: outlineOnly ? c(s, p.hover ? st.soft : st.paper) : c(s, p.disabled ? st.soft : st.accent), line: c( s, outlineOnly || st.rough ? st.ink : st.accent, ), width: 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, seed: seedOf(p.el) }, st) 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, { fill: c(s, st.paper), line: c( s, chosen ? st.accent : st.rough ? st.ink : st.soft, ), width: chosen ? 3 : 1.5, }) ring(p, s) }, panel(p, s) { lift(p, s, 14) body(p, s, { fill: c(s, st.paper), line: c(s, st.ink), width: 1.5, solid: true, }) }, }}function fieldPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, lift, body } = hands return { 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, st, { color: c(s, st.soft), width: 1 }) }, field(p, s) { const line = p.invalid ? c(s, '--veneer-color-critical-solid') : c(s, p.focus > 0.5 ? st.accent : st.soft) body(p, s, { fill: c(s, st.paper), line, width: 1.5 + p.focus, solid: 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, st, { color: c(s, st.ink), width: 1.6, }) } }, // 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, { fill: c(s, st.paper), line: c(s, st.rough ? st.ink : st.soft), width: 1.5, solid: true, }) }, option(p, s) { if (p.hover > 0.5) body(p, s, { fill: c(s, st.accent), line: c(s, st.accent), width: 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, st, { color: c(s, st.accent), width: 3, }) } }, }}function checkPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { 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, { fill: c(s, p.hover ? st.soft : st.paper), line: c( s, st.rough ? st.ink : p.on > 0.5 ? st.accent : st.soft, ), width: 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, { ...st, hatch: false }, { color: c(s, st.accent), box: [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, st, { color: c(s, st.accent), width: 2.6, }) ctx.setLineDash([]) } } ring({ ...p, r: round }, s) }, }}function switchPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { 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, { fill: p.on > 0.5 ? c(s, st.accent) : c(s, st.soft), line: c(s, st.rough ? st.ink : st.soft), width: 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, st, { color: c(s, st.ink), width: 1.2 }) ring({ ...p, r: p.h / 2 }, s) }, }}function rangeMeterPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { 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, st, { color: c(s, st.soft), width: 4 }) ctx.beginPath() ctx.moveTo(p.x + 2, y) ctx.lineTo(kx, y) stroke(ctx, st, { color: c(s, st.accent), width: 4, }) ctx.beginPath() ctx.arc(kx, y, 8 + p.hover * 2, 0, Math.PI * 2) ctx.fillStyle = c(s, st.paper) ctx.fill() stroke(ctx, st, { color: c(s, st.accent), width: 2.5, }) 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, { fill: c(s, st.paper), line: c(s, st.soft), width: 1.2, }) const ctx = s.ctx outline( ctx, { ...p, w: Math.max(p.h, p.w * p.value), r: p.h / 2, seed: seedOf(p.el) + 5, }, st, ) paint(ctx, st, { color: c(s, st.accent), box: [p.x, p.y, p.w * p.value, p.h], }) }, }}function chipRulePainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c, ring, body } = hands return { chip(p, s) { body({ ...p, r: p.h / 2 }, s, { fill: c( s, p.on > 0.5 ? st.accent : p.hover ? st.soft : st.paper, ), line: c(s, p.on > 0.5 ? st.accent : st.soft), width: 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, st, { color: c(s, st.soft), width: 1.5, }) }, }}function detailPainters( st: Style, hands: Hands,): Record<string, StagePainter> { const { c } = hands return { 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, }}function makeFocusRing(st: Style, c: PaintColor) { return (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, { x: p.x - 4, y: p.y - 4, w: p.w + 8, h: p.h + 8, r: p.r + 4, seed: seedOf(p.el) + 3, }, st, ) stroke(ctx, st, { color: c(s, st.accent), width: 2 }) ctx.setLineDash([]) }}function makeSurfaceLift(st: Style, c: PaintColor) { return (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, seed: seedOf(p.el) }, st) ctx.fillStyle = c(s, st.paper) ctx.fill() ctx.shadowColor = 'transparent' } if (st.shadow === 'offset') { outline( ctx, { ...p, x: p.x + depth, y: p.y + depth, seed: seedOf(p.el) + 1, }, st, ) paint(ctx, st, { color: c(s, st.soft), box: [p.x + depth, p.y + depth, p.w, p.h], density: 0.7, }) } }}type BodyLook = Parameters<PaintBody>[2]function makeSurfaceBody(st: Style) { return ( p: StagePiece, s: Scene, { fill, line, width = 1.5, solid = false }: BodyLook, ) => { const ctx = s.ctx outline(ctx, { ...p, seed: seedOf(p.el) }, st) paint(ctx, solid ? { ...st, hatch: false } : st, { color: fill, box: [p.x, p.y, p.w, p.h], }) outline(ctx, { ...p, seed: seedOf(p.el) }, st) stroke(ctx, st, { color: line, width }) }}function makeOverlay(st: Style, c: PaintColor) { return (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, st, { color: c(s, st.accent), width: 2, }) } 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() } }}function makePlain(st: Style, c: PaintColor) { return (s: Scene) => { s.ctx.fillStyle = c(s, st.paper) s.ctx.fillRect(0, 0, s.width, s.height) }}function makeVeil() { return (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 }}function lookKit(st: Style) { const c = (s: Scene, name: string) => s.color(name) const ring = makeFocusRing(st, c) const lift = makeSurfaceLift(st, c) // `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 = makeSurfaceBody(st) const hands = { c, ring, lift, body } const painters: Record<string, StagePainter> = { ...surfacePainters(st, hands), ...fieldPainters(st, hands), ...checkPainters(st, hands), ...switchPainters(st, hands), ...rangeMeterPainters(st, hands), ...chipRulePainters(st, hands), ...detailPainters(st, hands), } const overlay = makeOverlay(st, c) const plain = makePlain(st, c) // A modal's stage covers the page with a wash that // fades in each time it opens. const veil = makeVeil() return { stage: { painters, overlay, background: st.ground }, plain, veil, style: st, }}type WordInk = { fill: (ctx: CanvasRenderingContext2D) => void tilt?: number}const words = ( p: StagePiece, s: Scene, { fill, tilt = 0 }: WordInk,) => { 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(--veneer-color-text))', paper: 'var(--stage-paper, var(--veneer-color-surface))', accent: 'var(--stage-accent, ' + 'var(--veneer-color-accent-solid))',}const LOOKS = { flat: lookKit({ ...TOKENS, soft: 'var(--stage-soft, var(--veneer-color-border))', onFill: 'var(--veneer-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( '--veneer-color-surface-sunken', ) ctx.fillRect(0, 0, s.width, s.height) ctx.fillStyle = s.color('--veneer-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( '--veneer-color-accent-soft', ) ctx.fillRect(p.x, p.y, p.w, p.h) for (const [i, tone] of [ '--veneer-color-accent-solid', '--veneer-color-info-solid', '--veneer-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, { fill: (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('--veneer-color-info-solid'), ) ctx.fillStyle = g }, }) }, }), sketch: lookKit({ ...TOKENS, soft: 'var(--stage-soft, ' + 'var(--veneer-color-text-subtle))', onFill: 'var(--veneer-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( '--veneer-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( '--veneer-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, r: 0, seed: seedOf(p.el) }, LOOKS.sketch.style, ) stroke(ctx, LOOKS.sketch.style, { color: s.color(TOKENS.ink), width: 1.5, }) }, type(p, s) { words(p, s, { fill: (ctx) => (ctx.fillStyle = s.color(TOKENS.ink)), tilt: 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, { fill: (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 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>)const SKETCH_CHOOSE = lookStyle( 'sketch', 'padding:28px 28px 28px 40px;min-block-size:300px',)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