Skip to content
kerfjsGitHub

StageSelect

A select a stage draws, list and all, in whatever look the stage around it has.

SourcePackage @kerf-js/veneer

Preview

tsx
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/veneer

Usage

tsx
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.

tsx
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.

tsx
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-activedescendant names 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. name puts the value in a form through a hidden input.

API Reference

PropTypeDefaultDescription
optionsStageOption[]—Strings, or { value, label, disabled }.
valuestring—The value it starts on. The first option when left out.
namestring—Puts the value in a form, through a hidden input.
disabledbooleanfalseTurns it off, and takes it out of the tab order.
EventTypeDefaultDescription
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

RelatedCanvas stageDither stageSelect