Design Lounge
Install

Nº 601 of 605

Lamp configurator with materials

A product page for an SVG table lamp: material, glaze and size radio groups redraw it, with a live price breakdown, delivery window and add to bag.

Preview

Open full size
1280 × 800 · 14.7 KB · 207 lines

Use it

Free to use · credit appreciated

1 Once, in your project · Node 22.20+

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Lamp configurator with materials" (piece product-configurator-material) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

195 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The product page for the Orla table lamp from Hessle & Vey, an invented lighting workshop. The left two thirds is a dim green room with a table edge, and the lamp is drawn in SVG: a dome shade with a lit underside, a stem and a turned base. Three real radio groups on the right (Material, Shade glaze, Size) redraw the lamp in place. They swap the stem and base fill between brushed aluminium, oak grain and glazed ceramic, recolour the shade, and scale the whole lamp from its foot. A price breakdown, a total in 44px Fraunces, a delivery window that depends on the material, and a brass “Add to bag” pill follow the choices. The detail worth copying: every picker is a native <input type="radio"> with appearance:none, styled on the input itself with a 2px #8D978F edge, so arrow keys, form semantics and the 3:1 edge all come free.

Reference behaviour

  1. Initial state: Medium, Oiled oak, Moss. The lamp is drawn at scale 1, the total reads £290 (245 + 45 + 0, delivery free), delivery reads “Arrives Mon 9 to Wed 11 Nov · turned to order”, and the button reads “Add to bag · £290”.
  2. Material cards (Aluminium Included, Oiled oak +£45, Ceramic +£60): choosing one changes the stem and base fill over 420ms, the legend value, the breakdown row, the delivery line and the caption. The chosen card gets a brass border and an 8% brass wash.
  3. Shade glaze swatches (Chalk, Ochre, Moss, Oxblood +£15, Slate): choosing one recolours the shade path over 420ms and updates the legend value (“Shade glaze Oxblood”) and the glaze row. The chosen swatch has an ink border and a 2px brass ring 3px outside it.
  4. Size (Small 33 cm £185, Medium 41 cm £245, Large 48 cm £320): the lamp scales from its foot to .8, 1 or 1.16 over 420ms on expo-out, and the dimensions note bottom right updates (“41 cm tall / Shade 30 cm across”).
  5. Every change re-renders the breakdown (lamp and size, material, glaze, delivery), the total and the button label. The total ticks: it fades up from 20% opacity and 6px lower over 360ms.
  6. Delivery is £9.50 below a £250 subtotal and Free from £250. Small, aluminium, chalk costs £194.50.
  7. Arrow keys move within each group and wrap (native radio behaviour); Tab moves between groups.
  8. “Add to bag” increments the bag count in the header, sets the bag’s aria-label to “Bag, 1 item”, turns the button ink with the label “Added to bag”, and announces “Orla table lamp, Medium, added to bag”. Any later change resets the button to “Add to bag · £…”.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────────┐
│ Hessle & Vey   Lighting Seating Tables Workshop              (Bag ●0)   │ 56
├────────────────────────────────────────────┬─────────────────────────────┤
│ stage (1fr = 760)                          │ panel 520, padding 26/36    │
│   warm light pool at 50% 34%               │ Lighting / Table lamps      │
│            ●                               │ Orla table lamp   52/300    │
│        ╭───────╮  shade (glaze)            │ lede, 420 max               │
│       ╱         ╲                          │ MATERIAL  [○ Alu][◉ Oak][○ Cer]│
│       ‾‾‾‾‾‾‾‾‾‾‾ lit underside            │ SHADE GLAZE ● ● ◉ ● ●  40px  │
│            ┃   stem (material)             │ SIZE      [○ S][◉ M][○ L]   │
│ ───────────┃──────── table edge at 72%     │ ─────────────────────────── │
│         ▁▁▁┻▁▁▁ base (material)            │ breakdown dl      £290 44px │
│ Shown: Medium…                 41 cm tall  │ [van] Arrives Mon 9 to Wed 11│
│ Light: 2700 K…        Shade 30 cm across   │ [   Add to bag · £290   ] 52│
└────────────────────────────────────────────┴─────────────────────────────┘

([van] in the wireframe is an 18px inline SVG truck icon in --brass.)

  • <header class="top">: serif wordmark, <nav aria-label="Shop"> with aria-current on Lighting, a bag <button> with a 1px edge and a brass count badge.
  • <section class="stage" aria-label="Preview">: the SVG (viewBox="0 0 400 520", height:84%, anchored bottom:8%, centred), the caption <p id="shown"> (the SVG’s aria-labelledby), and the dimensions note.
  • <section class="panel" aria-labelledby="t">: crumb, h1, lede, three <fieldset>s each with a <legend> holding the group name and the chosen value, then .sum with the breakdown <dl>, the total (aria-live="polite"), the delivery line and the add button.
  • SVG layers, back to front: floor shadow (unscaled), then <g id="lamp"> with the light pool, stem rect (18×268), base path (144×18 cylinder with a 12% white top), the lit underside ellipse, the shade path, a white highlight copy of the shade, and the 7px finial.

Tokens

:root {
  /* room and surfaces */
  --bg: #1b221f;          /* page, panel, header */
  --stage: #27302b;       /* wall, top 72% of the stage */
  --stage-2: #313b35;     /* table surface, bottom 28% */
  --line: #36403a;        /* hairlines, resting card border */
  --edge: #8d978f;        /* 2px radio and swatch edge, bag edge: 5.4:1 on --bg */
  /* ink */
  --ink: #efeae0;         /* text, chosen swatch border, done button */
  --ink-2: #c4c2b8;       /* lede, legends, breakdown labels */
  --ink-3: #a5a69c;       /* crumb, option sub-labels */
  --brass: #d9ad5f;       /* the one accent: chosen state, button, focus */
  --brass-hover: #e4bb72;
  --brass-ink: #1f1708;   /* text on brass */
  --glow: #ffe2a8;        /* light pool, lit underside */
  /* materials (SVG paint servers) */
  --alu: #7c8285 / #e2e4e2 / #6f7477;     /* horizontal 3-stop gradient */
  --oak: #9a6a3e, grain #7d522c;          /* 10×40 pattern, two wavy strokes */
  --ceramic: #bdb4a2 / #f3eee3 / #b3aa98; /* horizontal 3-stop gradient */
  /* glazes */
  --chalk: #e7e1d4; --ochre: #c98b2e; --moss: #6e7a45; --oxblood: #7a2f2a; --slate: #3a4652;
  /* type */
  --serif: "Fraunces", Georgia, serif;
  --sans: "Albert Sans", system-ui, sans-serif;
  --fs-title: 52px; --fs-total: 44px; --fs-body: 15px; --fs-ui: 14px; --fs-small: 13px; --fs-legend: 12px;
  /* space and shape */
  --panel-w: 520px; --pad: 26px 36px 24px; --stack: 16px;
  --r: 10px; --r-pill: 999px;
  --swatch: 40px; --dot: 18px; --button-h: 52px;
  /* motion */
  --ease: cubic-bezier(.2,.7,.2,1);
  --out: cubic-bezier(.16,1,.3,1);
  --t: 180ms; --t-lay: 420ms; --t-tick: 360ms;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Product titleFraunces (opsz 144)52px3001−0.02emsentence
TotalFraunces44px4001−0.02emtabular
WordmarkFraunces20px5001−0.01emas set
LegendAlbert Sans12px7001.45+0.08emUPPERCASE
Legend valueAlbert Sans12px5001.450sentence
Option nameAlbert Sans14px6001.20sentence
Option sub-labelAlbert Sans12px4001.450sentence
Lede, breakdownAlbert Sans14px / 13px4001.450sentence
ButtonAlbert Sans16px70010sentence

Motion

ElementTriggerPropertyFrom → ToDurationEasing
#lamp groupsize changetransformscale(.8 / 1 / 1.16), origin 200 500420ms--out
Shade, stem, baseglaze or materialfillold → new420ms--ease
Totalany changeopacity, translateY.2, 6px → 1, 0360ms--out
Option cardhover, checkedborder, background--line → --edge / --brass180ms--ease
Radio dotcheckedinset box-shadownone → 4px gap + 9px brass180ms--ease
Swatchhoverscale1 → 1.06180ms--ease
Add buttonactivescale1 → .98180ms--ease

Reduced motion: all durations 1ms and the total does not tick. Gradient fills on SVG do not interpolate in most engines; the swap is instant there, which is fine.

States

  • Radio dot rest: 18px circle, 2px --edge border, transparent fill. Checked: border --brass, inner 9px brass dot made with two inset shadows. Focus-visible: 2px brass outline, 3px offset.
  • Option card: 1px --line; hover --edge; checked (:has(:checked)) --brass border and rgba(217,173,95,.08) wash.
  • Swatch: 2px --edge border over the glaze fill; checked: border --ink and a 2px brass outline at 3px offset; focus-visible: 2px ink outline.
  • Add button: brass, hover --brass-hover, active scale .98, done: --ink fill with “Added to bag”.
  • Delivery: in stock (aluminium), turned to order (oak), thrown and fired to order (ceramic).

Accessibility

  • Each group is a <fieldset> with a <legend>; the legend’s value span repeats the choice for sighted users.
  • Material and size inputs are wrapped by their <label>, so the whole card is the hit area (about 60px tall). Swatches are bare inputs with aria-label (“Oxblood, plus 15 pounds”).
  • Native radio keyboard: arrows move and select, wrapping at the ends; Tab enters the checked one.
  • The edge contrast lives on the input element itself: --edge on --bg is 5.4:1, so a checker that measures the control (not its card) passes. The chalk swatch, near-white on dark, still shows its edge.
  • The SVG is role="img" labelled by the caption, which always states size, material and glaze.
  • Total is aria-live="polite"; the add confirmation goes to a separate polite live region.
  • Contrast: --ink-3 on --bg 6.6:1; --brass-ink on --brass 8.5:1.

Responsive rules

  • ≥ 1280: stage 1fr, panel 520px.
  • 1024–1100: panel 460px.
  • 860–1023: as above; option cards may wrap their sub-labels.
  • < 860 (390 phones): one column. Header hides the nav. Stage 400px tall, SVG at 76% height anchored 17% from the bottom, caption at 16px/14px; the dimensions note hides. Option cards stay three across but stack the dot above the text (10px padding). Title 40px.

Acceptance checklist

Always

  • Every picker is a native input type="radio" in a named group inside a fieldset with a legend.
  • The ≥ 3:1 edge (2px) is on the input element, not on its label or card.
  • Arrow keys change the choice in each group and the product redraws.
  • The product drawing changes material fill, shade colour and scale from one render function.
  • The breakdown lists base, material, glaze and delivery, and always sums to the total.
  • Delivery window text changes with the material.
  • Add to bag updates the header count, its aria-label, and a live region.
  • No horizontal scroll at 390; reduced motion removes the scale and tick animation.

This demo

  • Default is Medium, Oiled oak, Moss at £290 with free delivery.
  • Small, Aluminium, Chalk shows £185 and Delivery £9.50, total £194.50.
  • Large, Ceramic, Oxblood shows £320 + £60 + £15 = £395 and “Arrives Wed 18 to Mon 23 Nov”.
  • Lamp scales are .8, 1 and 1.16 from the foot (200, 500 in SVG units).

Implementation notes

  1. Style the radio itself. appearance:none keeps the input focusable, checkable and arrow-key driven, and lets the edge sit on the element a checker measures. The checked dot is two inset shadows, so no pseudo-element is needed (inputs cannot have one in most engines).
input[type=radio] { appearance: none; margin: 0; flex: none; cursor: pointer; }
.dot { width: 18px; height: 18px; border-radius: 50%;
  border: 2px solid var(--edge); background: transparent; }
.dot:checked { border-color: var(--brass);
  box-shadow: inset 0 0 0 4px var(--bg), inset 0 0 0 9px var(--brass); }
.chip { width: 40px; height: 40px; border-radius: 50%; border: 2px solid var(--edge); }
.chip:checked { border-color: var(--ink); outline: 2px solid var(--brass); outline-offset: 3px; }
.opt:has(:checked) { border-color: var(--brass); background: rgba(217,173,95,.08); }
  1. One render from the checked inputs. Keep prices and copy on data-* attributes and read the whole state on every change, so the drawing, breakdown and button can never disagree.
function render() {
  const m = q('mat'), c = q('col'), s = q('size');
  const sub = +s.dataset.p + +m.dataset.p + +c.dataset.p;
  const del = sub >= 250 ? 0 : 9.5;
  shade.setAttribute('fill', c.value);
  stemAndBase.forEach(e => e.setAttribute('fill', FILL[m.value]));
  lamp.style.transform = `scale(${s.value})`;
  total.textContent = money(sub + del);
}
document.addEventListener('change', e => e.target.matches('input[type=radio]') && render());
  1. Scale from the foot. Set transform-origin: 200px 500px on the SVG group (the base centre in viewBox units) and keep the floor shadow outside the group, or a large lamp floats above the table. Re-trigger the total’s tick by removing the class, reading offsetWidth, and adding it again; skip it on the first render.

Details

Palette
Type
Fraunces · Albert Sans
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Abel

A catalogue. One product at a time.

All sources