Motif· VectoJS gallery
WebGPUcore 1.26.0GitHub

Ceramic Keys

Glazed-clay keycaps and a kiln-fired toggle — speckled glaze, soft studio shadows, and a spring press that physically depresses each key.

import { Scene, Entity, type IRenderer } from '@vectojs/core';

// Ceramic keys: glazed-clay keycaps with a physical press. The material is
// baked ONCE into offscreen sprites (glaze gradients, speckle, rim shading,
// a blurred studio shadow) — per-frame work is just drawImage plus the
// engine's spring on y/scale. That split — expensive texture offline, cheap
// motion online — is the pattern for any tactile material in VectoJS.

const app = document.getElementById('app')!;
const canvas = document.getElementById('canvas') as HTMLCanvasElement;

const MUTED = '#8a8073';
const SPRITE_SCALE = Math.min(window.devicePixelRatio || 1, 2);

// Deterministic speckle so every load fires the same kiln.
function makeRandom(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (s * 1664525 + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

function shade(hex: string, amount: number): string {
  const n = parseInt(hex.slice(1), 16);
  const ch = (v: number) => Math.max(0, Math.min(255, Math.round(v + amount)));
  const rr = ch((n >> 16) & 255);
  const gg = ch((n >> 8) & 255);
  const bb = ch(n & 255);
  return `rgb(${rr}, ${gg}, ${bb})`;
}

// Bake one glazed keycap body at 2x. Layers, bottom to top: base gradient,
// radial glaze bloom, rim shading, speckle, crisp highlight arc.
function bakeCeramic(
  width: number,
  height: number,
  radius: number,
  glaze: string,
  seed: number,
): HTMLCanvasElement {
  const s = SPRITE_SCALE;
  const c = document.createElement('canvas');
  c.width = width * s;
  c.height = height * s;
  const ctx = c.getContext('2d')!;
  ctx.scale(s, s);

  ctx.beginPath();
  ctx.roundRect(0, 0, width, height, radius);
  ctx.clip();

  const base = ctx.createLinearGradient(0, 0, 0, height);
  base.addColorStop(0, shade(glaze, 34));
  base.addColorStop(0.55, glaze);
  base.addColorStop(1, shade(glaze, -26));
  ctx.fillStyle = base;
  ctx.fillRect(0, 0, width, height);

  // Glaze bloom: the wet-looking pool of light where the glaze ran thick.
  const bloom = ctx.createRadialGradient(
    width * 0.32,
    height * 0.26,
    4,
    width * 0.32,
    height * 0.26,
    width * 0.7,
  );
  bloom.addColorStop(0, 'rgba(255, 255, 255, 0.5)');
  bloom.addColorStop(0.45, 'rgba(255, 255, 255, 0.12)');
  bloom.addColorStop(1, 'rgba(255, 255, 255, 0)');
  ctx.fillStyle = bloom;
  ctx.fillRect(0, 0, width, height);

  // Fired-clay speckle: tiny dark flecks with a few pale ones.
  const rand = makeRandom(seed);
  for (let i = 0; i < 260; i++) {
    const dark = rand() > 0.22;
    ctx.fillStyle = dark
      ? `rgba(58, 44, 32, ${0.04 + rand() * 0.09})`
      : `rgba(255, 255, 255, ${0.08 + rand() * 0.1})`;
    const d = 0.6 + rand() * 1.7;
    ctx.beginPath();
    ctx.arc(rand() * width, rand() * height, d, 0, Math.PI * 2);
    ctx.fill();
  }

  // Rim: darker toward the bottom edge (clay thickness), bright top lip.
  ctx.lineWidth = 3;
  ctx.strokeStyle = 'rgba(58, 44, 32, 0.24)';
  ctx.beginPath();
  ctx.roundRect(0.5, 2, width - 1, height - 2.5, radius);
  ctx.stroke();

  // Top lip highlight: an OPEN arc tracing just the top-rounded edge, not a
  // closed rect — roundRect()+stroke() on a half-height box draws its full
  // perimeter, including a hard straight line across the middle of the key
  // where the box's bottom edge falls. Trace only the rounded corners + the
  // straight span between them instead.
  ctx.lineWidth = 1.5;
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.65)';
  const r = radius - 1;
  ctx.beginPath();
  ctx.moveTo(1.5, height * 0.42);
  ctx.lineTo(1.5, r + 1.25);
  ctx.arcTo(1.5, 1.25, 1.5 + r, 1.25, r);
  ctx.lineTo(width - 1.5 - r, 1.25);
  ctx.arcTo(width - 1.5, 1.25, width - 1.5, r + 1.25, r);
  ctx.lineTo(width - 1.5, height * 0.42);
  ctx.stroke();

  return c;
}

// Soft studio shadow, baked via filter blur. Drawn UNDER the key; the press
// counter-offsets it so the shadow stays put while the cap sinks toward it.
function bakeShadow(
  width: number,
  height: number,
  radius: number,
): { canvas: HTMLCanvasElement; pad: number } {
  const s = SPRITE_SCALE;
  const pad = 26;
  const c = document.createElement('canvas');
  c.width = (width + pad * 2) * s;
  c.height = (height + pad * 2) * s;
  const ctx = c.getContext('2d')!;
  ctx.scale(s, s);
  ctx.filter = 'blur(11px)';
  ctx.fillStyle = 'rgba(70, 55, 40, 0.38)';
  ctx.beginPath();
  ctx.roundRect(pad, pad + 4, width, height - 2, radius);
  ctx.fill();
  return { canvas: c, pad };
}

const PRESS_DEPTH = 5;

class CeramicKey extends Entity {
  label: string;
  body: HTMLCanvasElement;
  shadow: { canvas: HTMLCanvasElement; pad: number };
  baseY = 0;

  constructor(
    label: string,
    glaze: string,
    width: number,
    height: number,
    seed: number,
    onPress: (label: string) => void,
  ) {
    super(`CeramicKey:${label}`);
    this.width = width;
    this.height = height;
    this.label = label;
    this.interactive = true;
    this.body = bakeCeramic(width, height, 16, glaze, seed);
    this.shadow = bakeShadow(width, height, 16);
    // A stiff, slightly underdamped spring: the cap thunks down fast and
    // pops back with one small overshoot — clay, not rubber.
    this.setTransition({ y: { stiffness: 620, damping: 21 } });

    this.on('pointerdown', () => {
      this.y = this.baseY + PRESS_DEPTH;
      onPress(label);
    });
    this.on('pointerup', () => {
      this.y = this.baseY;
    });
    this.on('pointerleave', () => {
      this.y = this.baseY;
    });
  }

  place(x: number, y: number) {
    this.setPosition(x, y);
    this.baseY = y;
  }

  override isPointInside(gx: number, gy: number) {
    const p = this.worldToLocal(gx, gy);
    return !!p && p.x >= 0 && p.x <= this.width && p.y >= 0 && p.y <= this.height;
  }

  render(r: IRenderer) {
    // depth 0 (rested) → 1 (fully pressed), read from the live spring value.
    const depth = Math.max(0, Math.min(1, (this.y - this.baseY) / PRESS_DEPTH));
    const { canvas: sh, pad } = this.shadow;
    // Cancel the entity's own y-motion so the shadow stays on the "desk",
    // and tighten it as the cap approaches the surface.
    r.save();
    r.setGlobalAlpha(1 - depth * 0.45);
    r.drawImage(
      sh,
      -pad + depth * 2,
      -pad + (this.baseY - this.y) + depth * 3,
      sh.width / SPRITE_SCALE - depth * 4,
      sh.height / SPRITE_SCALE - depth * 4,
    );
    r.restore();
    r.drawImage(this.body, 0, 0, this.width, this.height);
    r.fillText(
      this.label,
      this.width / 2 - 8,
      this.height / 2 + 9,
      '700 24px "Inter", sans-serif',
      'rgba(42, 39, 35, 0.78)',
    );
  }
}

class CeramicToggle extends Entity {
  checked = false;
  knobSize: number;
  trackOff: HTMLCanvasElement;
  trackOn: HTMLCanvasElement;
  knobOff: HTMLCanvasElement;
  knobOn: HTMLCanvasElement;
  // t: knob travel AND glaze crossfade, integrated in update() — an
  // example of custom motion (hasPendingAnimations) beside engine springs.
  t = 0;
  tTarget = 0;
  vel = 0;

  constructor(width: number, height: number) {
    super('CeramicToggle');
    this.width = width;
    this.height = height;
    this.interactive = true;
    this.knobSize = height - 10;
    // Recessed track: an inverted glaze (dark top lip = carved into clay).
    // Two variants ("off" raw clay, "on" warm coral wash) crossfade with the
    // knob so the whole toggle — not just the small knob — reads as changed.
    const s = SPRITE_SCALE;

    function bakeTrack(
      w: number,
      h: number,
      scale: number,
      stops: [string, string, string],
    ): HTMLCanvasElement {
      const c = document.createElement('canvas');
      c.width = w * scale;
      c.height = h * scale;
      const ctx = c.getContext('2d')!;
      ctx.scale(scale, scale);
      ctx.beginPath();
      ctx.roundRect(0, 0, w, h, h / 2);
      ctx.clip();
      const g = ctx.createLinearGradient(0, 0, 0, h);
      g.addColorStop(0, stops[0]);
      g.addColorStop(0.5, stops[1]);
      g.addColorStop(1, stops[2]);
      ctx.fillStyle = g;
      ctx.fillRect(0, 0, w, h);
      ctx.lineWidth = 2.5;
      ctx.strokeStyle = 'rgba(58, 44, 32, 0.3)';
      ctx.beginPath();
      ctx.roundRect(1, 1.75, w - 2, h, h / 2);
      ctx.stroke();
      return c;
    }

    this.trackOff = bakeTrack(width, height, s, ['#cfc4b2', '#e6ddcc', '#f2ecdf']);
    this.trackOn = bakeTrack(width, height, s, ['#e2a184', '#eec19f', '#f6dcc3']);

    this.knobOff = bakeCeramic(this.knobSize, this.knobSize, this.knobSize / 2, '#e9dfcd', 7);
    this.knobOn = bakeCeramic(this.knobSize, this.knobSize, this.knobSize / 2, '#d97757', 8);

    this.on('click', () => {
      this.checked = !this.checked;
      this.tTarget = this.checked ? 1 : 0;
      this.scene?.markDirty();
    });
  }

  override isPointInside(gx: number, gy: number) {
    const p = this.worldToLocal(gx, gy);
    return !!p && p.x >= 0 && p.x <= this.width && p.y >= 0 && p.y <= this.height;
  }

  // Scene contract: while this returns true the idle throttle stays off, so
  // the hand-rolled spring below animates at full rate even in onDemand mode.
  override hasPendingAnimations() {
    return (
      Math.abs(this.t - this.tTarget) > 0.001 ||
      Math.abs(this.vel) > 0.001 ||
      super.hasPendingAnimations()
    );
  }

  override update(dt: number, time: number) {
    super.update(dt, time);
    const step = Math.min(dt, 32) / 1000;
    const k = 210;
    const d = 19;
    this.vel += (this.tTarget - this.t) * k * step - this.vel * d * step;
    this.t += this.vel * step;
  }

  render(r: IRenderer) {
    const t = Math.max(0, Math.min(1, this.t));
    const x = 5 + t * (this.width - this.knobSize - 10);
    r.save();
    r.setGlobalAlpha(1);
    r.drawImage(this.trackOff, 0, 0, this.width, this.height);
    r.setGlobalAlpha(t);
    r.drawImage(this.trackOn, 0, 0, this.width, this.height);
    r.setGlobalAlpha(1);
    r.drawImage(this.knobOff, x, 5, this.knobSize, this.knobSize);
    r.setGlobalAlpha(t);
    r.drawImage(this.knobOn, x, 5, this.knobSize, this.knobSize);
    r.restore();
    r.fillText(
      this.checked ? 'glaze: coral' : 'glaze: raw',
      this.width + 16,
      this.height / 2 + 5,
      '400 14px "Inter", sans-serif',
      MUTED,
    );
  }
}

// Material demos are static between interactions, so paint only on demand.
const scene = new Scene(canvas, {
  maxFPS: 60,
  disableWindowResize: true,
  maxDPR: 2,
  renderMode: 'onDemand',
});

let pressed = '—';
class Board extends Entity {
  isPointInside() {
    return false;
  }
  render(r: IRenderer) {
    r.fillText(
      `Fired at 1260 °C · last press: ${pressed}`,
      0,
      0,
      '400 14px "Inter", sans-serif',
      MUTED,
    );
  }
}

const board = new Board('Board');
scene.add(board);

const KEY_W = 112;
const KEY_H = 96;

function onPress(label: string) {
  pressed = label;
}

const keys = [
  new CeramicKey('⌘', '#d97757', KEY_W, KEY_H, 11, onPress),
  new CeramicKey('⇧', '#7fb6a4', KEY_W, KEY_H, 12, onPress),
  new CeramicKey('⏎', '#e9d8a6', KEY_W, KEY_H, 13, onPress),
];
// One at a time: Scene.add takes a single entity (unlike the variadic
// Entity.add) — in plain JS a spread here silently drops the extras.
for (const key of keys) scene.add(key);

const toggle = new CeramicToggle(96, 44);
scene.add(toggle);

function fit() {
  const w = app.clientWidth;
  const h = app.clientHeight;
  if (w === 0 || h === 0) return;
  scene.resize(w, h);
  const totalW = keys.length * KEY_W + (keys.length - 1) * 22;
  const left = (w - totalW) / 2;
  const top = h / 2 - KEY_H / 2 - 8;
  keys.forEach((key, i) => key.place(left + i * (KEY_W + 22), top));
  board.setPosition(left, top - 74);
  toggle.setPosition(left, top + KEY_H + 40);
  scene.markDirty();
}

const observer = new ResizeObserver(fit);
observer.observe(app);

scene.start();
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="/no-ff-webgpu.js"></script>
    <title>Ceramic Keys — Motif</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
      rel="stylesheet"
    />
    <style>
      * {
        margin: 0;
        box-sizing: border-box;
      }
      html,
      body {
        width: 100%;
        height: 100%;
        overflow: hidden;
        background: #f7f4ee;
      }
      #app {
        position: relative;
        width: 100%;
        height: 100%;
      }
      canvas {
        display: block;
        width: 100%;
        height: 100%;
      }
    </style>
    <script type="importmap">
      {
        "imports": {
          "@vectojs/core": "https://esm.sh/@vectojs/core@1.26.0"
        }
      }
    </script>
  </head>
  <body>
    <div id="app"><canvas id="canvas"></canvas></div>
    <script type="module" src="./demo.js"></script>
  </body>
</html>
liveesm.sh@vectojs/coreMIT — copy freely