Design Lounge
Install

Nº 389 of 520

Salon wall planner

A gallery wall of seven mixed frames over a sofa. Drag to rearrange on a 10 cm snap grid; frames swing on their nail when hung. Pick a frame finish.

Preview

Open full size
1280 × 800 · 18.9 KB · 222 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Salon wall planner" (piece gallery-wall-frames) 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

222 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

A gallery-wall planner for a home décor app called Hangline. A sage plaster wall, drawn to scale (900×600px = 450×300 cm), holds seven framed prints in a salon cluster above a rust sofa, with a floor lamp at the right. Each frame hangs from a visible wire and nail. You pick a frame up and drag it. A dotted 10 cm grid fades in, and a dashed outline shows where it will land. The frame tilts with your hand while it moves. When you let go it snaps to the grid and swings on its nail, the swing dying away over one second. A left panel picks the frame finish (black ash, natural oak, gilt, white lacquer) for the selected frame or all frames. The detail worth copying is the hang swing. It uses the CSS rotate property around a transform-origin 22px above the frame (the nail), so it never fights the position transitions.

Reference behaviour

  1. First frame: seven frames hang in a cluster. Harbour light (gilt, 100×70 cm) is selected and has a 2px ink ring. The panel shows its name, size and position. The style radio follows the selected frame, so Gilt is checked. Snap is on.
  2. Pressing a frame selects it, raises it (z-index), scales it to 1.03 and deepens its shadow. The wall shows a 20px dot grid (opacity 0 → 0.7, 200ms).
  3. Dragging moves the frame 1:1 with the pointer (compensated for the wall’s scale). The frame tilts by horizontal speed: rotate = clamp(dx × 0.5, −6°, 6°) per move event, eased over 120ms.
  4. A dashed 2px outline previews the snapped landing spot. If that spot overlaps another frame, the outline turns terracotta #c4532e.
  5. Frames are clamped inside the wall: x from 0 to 900 − w, y from 30 to 440 − h (they never sink behind the sofa).
  6. Release: the frame snaps to the grid (left/top transition 240ms expo-out), the tilt returns to 0, and after 180ms it swings: rotate 5° → −3° → 1.6° → −0.7° → 0 over 1000ms. The status line announces the new position, and warns if it overlaps.
  7. Clicking a finish swatch re-frames the selected print (border thickness and colour change) and gives it a small 2.4° swing. “Apply to all frames” copies the selected finish to all seven, swinging each with a 50ms stagger.
  8. Snap switch off: the grid dots stay hidden, drops land where released, and arrow-key nudges use 1 cm (2px).
  9. Keyboard: focus a frame (Tab) to select it. Arrows nudge one grid step (10 cm). Shift+arrow nudges five. Each nudge gives a 1.6° swing. Enter/Space re-hangs it with a full swing.
  10. “Rehang all” swings all seven frames at 6°, staggered by 70ms.
  11. Reduced motion: no tilt, no swing, no snap slide; positions change instantly.

Structure

1280 × 800, grid: 300px | 1fr
┌──────────────┬──────────────────────────────────────────────────┐
│ ⌂ Hangline   │  stage (#e7e2d6), padding 28                      │
│ Hallway,     │  ┌ wall 900×600, scaled to fit (--s) ───────────┐ │
│ north wall   │  │          ∧        ∧        ∧                  │ │
│ 450×300 · 7  │  │       [oak]   [ gilt  ]  [black]     ∧        │ │
│ FRAME STYLE  │  │               [       ]  [white]   [white]    │ │
│ [■ Black][□ Oak]│  [black] [  oak   ]                lamp       │ │
│ [□ Gilt][□ White]│                                   │          │ │
│ Apply to all │  │        ┌──── sofa 520×120 ────┐    │          │ │
│ ┌ SELECTED ┐ │  │▓▓▓▓▓▓▓▓▓▓ skirting 6 / floor 40 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ │
│ │Harbour.. │ │  └──────────────────────────────────────────────┘ │
│ Snap  (●)    │                                                    │
│ status       │                                                    │
│ (Rehang all) │                                                    │
└──────────────┴──────────────────────────────────────────────────┘
  • aside.panel (labelled “Wall settings”): brand, the only h1, size line, a role="radiogroup" of four role="radio" buttons, the “Apply to all” text button, a “Selected” card with an h2, a role="switch" button, a polite live status p, and the primary “Rehang all” button pinned to the bottom.
  • main.stage → .fit (reserves 900×600 × --s) → .wall (900×600, transform: scale(var(--s)), origin 0 0).
  • Wall decor (aria-hidden): lamp, sofa, floor, and the .ghost outline.
  • Seven .fr frames (role="button", tabindex="0", aria-pressed for the selection), each with an SVG wire, a .body (the moulding), a .mat, and an inline SVG print. The nail is ::after.

Tokens

:root {
  --paper: #f1ede4;     /* panel */
  --paper-2: #e7e2d6;   /* stage, selected card */
  --line: #d6cfbf;
  --ink: #22251f;
  --ink-2: #575a50;
  --ink-3: #7a7c70;
  --wall: #b9c0a8;      /* sage plaster */
  --wall-2: #aeb69c;
  --accent: #c4532e;    /* terracotta: CTA, links, overlap warning, focus */
  --accent-ink: #fff8f2;
  --sofa: #8f5e4a;
  --floor: #9a8a72;
  --sans: "Bricolage Grotesque", system-ui, sans-serif;
  --serif: "Instrument Serif", Georgia, serif;
  --ease: cubic-bezier(.2,.7,.2,1);
  --expo: cubic-bezier(.16,1,.3,1);
  --grid: 20px;         /* = 10 cm */
  --radius-card: 12px;
  --radius-swatch: 10px;
}

Finishes (per frame, via data-style):

FinishMoulding --tFill
Black ash9pxlinear-gradient(135deg, #2a2926, #161513)
Natural oak11pxlinear-gradient(135deg, #c79a63, #a87a46 60%, #b98a54) + 1px grain stripes #7a52281a every 4px
Gilt12pxlinear-gradient(135deg, #ead08f, #c29a55 35%, #8f6d35 60%, #e0bf78) + inset rings 2px #7a5a26, 4px #f0d79a
White lacquer9pxlinear-gradient(135deg, #fdfcf8, #ebe7dd) + inset 1px #0000001a

Mats are #faf7f0 with inset 0 1px 3px #0003, 0–14px per print. Frame shadow drop-shadow(0 8px 10px #2a2a1e40); dragging drop-shadow(0 22px 22px #2a2a1e55).

Typography

RoleFamilySize / lineWeightTrackingColour
BrandBricolage Grotesque15px600−0.01em--ink
h1Instrument Serif38px / 1400−0.01em--ink
Size lineBricolage Grotesque14px4000--ink-2
Section labelBricolage Grotesque11px5000.14em, upper--ink-3
SwatchBricolage Grotesque13px4000--ink
Selected h2Instrument Serif24px / 1.14000--ink
MeasurementsBricolage Grotesque13px, tabular4000--ink-2
StatusBricolage Grotesque12px4000--ink-3
CTABricolage Grotesque14px5000--accent-ink

Motion

ThingTriggerPropertyFrom → toDurationEasingReduced motion
Grid dotsdrag start/endopacity0 ↔ 0.7200ms--easeinstant
Liftpressscale, filter1 → 1.03, shadow deepens200ms--easeinstant
Carry tiltpointer moverotate→ clamp(dx·0.5, ±6°)120mslinear0°
Snapreleaseleft, topdrop point → grid point240ms--expoinstant
Hang swingrelease (+180ms)rotate5°, −3°, 1.6°, −0.7°, 0°1000ms--easenone
Nudge swingarrow keyrotate1.6° … 0°600ms--easenone
Finish changeswatchrotate2.4° … 0°600ms--easenone
Rehang allbuttonrotate6° … 0°, stagger 70ms1000ms--easenone
Ghostdragopacity0 → 0.55 (overlap 0.9)150ms--easeinstant

States

  • Frame resting: cursor: grab. Selected: aria-pressed="true", ring 0 0 0 2px --paper, 0 0 0 4px --ink on the moulding. Dragging: cursor: grabbing, z-index 50, lifted.
  • Ghost: dashed --ink at 0.55; overlapping: dashed --accent at 0.9.
  • Swatch: resting 1px --line on #fbf9f4; hover --ink-3 border; checked --ink border plus inset 1px ring on white.
  • Switch: off track #c9c3b4, on track --ink, knob slides 16px (200ms expo).
  • CTA: --accent, hover #ad4524.
  • Focus-visible: 2px --accent outline, offset 3px (frames offset 6px).

Accessibility

  • Frames are role="button" with tabindex="0", labelled “Harbour light, 100 by 70 cm”; aria-pressed marks the selection. Prints are aria-hidden.
  • Keyboard: Tab selects; arrows nudge 10 cm (1 cm with snap off); Shift ×5; Enter/Space re-hangs.
  • The finish picker is a radiogroup labelled “Frame style”; its checked state follows the selected frame.
  • Snap is a role="switch" with aria-checked.
  • The status line is aria-live="polite": positions in cm, plus an overlap warning. Overlap is never shown by colour alone.
  • Contrast: #22251f on #f1ede4 ≈ 14:1; #7a7c70 labels ≈ 4.1:1 (11px uppercase labels, upgrade to --ink-2 for strict AA); #fff8f2 on #c4532e ≈ 4.6:1.
  • Hit targets: swatches 52px, CTA 44px, switch row ≥ 40px.

Responsive rules

  • ≥1280: panel 300px, wall scaled to min(1.25, (stageW − 56) / 900, (innerHeight − 56) / 600).
  • 1024: same, wall about 0.75.
  • ≤900: one column; the wall comes first (order −1), panel below, page scrolls vertically. Scale from width only.
  • <640: wall about 0.38 at 375; pointer and touch drag still work because pointer maths divide by the measured scale.
  • No horizontal scroll at any width.

Acceptance checklist

Always

  • Frames drag with pointer and touch; coordinates are corrected for the wall’s scale.
  • A snap preview outline appears while dragging, and warns in the accent when the landing spot overlaps.
  • Drops snap to the grid when snap is on; frames stay inside the wall and above the furniture line.
  • Release triggers a damped swing around the nail (origin above the frame), not around the centre.
  • The tilt while carrying and the swing use rotate; position uses left/top; they never cancel each other.
  • The finish picker changes the selected frame and reflects the selected frame’s current finish.
  • Arrow keys move the focused frame by one grid step; Shift moves five.
  • Every move is announced in the live region.
  • Reduced motion removes tilt, swing and slide.

This demo

  • Wall 900×600 = 450×300 cm; grid 20px = 10 cm; sofa 520×120 at x 190.
  • Seven prints: Fennel study, Harbour light, Moon over Ost, Blue vase, Two figs, Contour Lake Ost, Swim club 1962.
  • Harbour light starts selected in gilt at 175 cm from left, 60 cm from top.
  • Wall #b9c0a8, accent #c4532e, panel #f1ede4.
  • Hang swing 5° → 0 in 1000ms.

Implementation notes

Drag in a scaled canvas. Measure the wall each press; divide by the scale.

el.addEventListener('pointerdown', ev => {
  const r = wall.getBoundingClientRect(), s = r.width / 900;
  drag = { r, s, ox: (ev.clientX - r.left) / s - it.x, oy: (ev.clientY - r.top) / s - it.y, lx: ev.clientX };
  el.setPointerCapture(ev.pointerId);
});
el.addEventListener('pointermove', ev => {
  if (!drag) return;
  it.x = (ev.clientX - drag.r.left) / drag.s - drag.ox;
  it.y = (ev.clientY - drag.r.top) / drag.s - drag.oy;
  clamp(it); place(it);
  el.style.setProperty('--tilt', Math.max(-6, Math.min(6, (ev.clientX - drag.lx) * .5)) + 'deg');
  drag.lx = ev.clientX;
});

The swing. Origin at the nail, the individual rotate property, WAAPI keyframes:

.fr { transform-origin: 50% -22px; rotate: var(--tilt, 0deg);
  transition: left .24s var(--expo), top .24s var(--expo); }
.fr.drag { transition: rotate .12s linear; }   /* no left/top transition while dragging */
const hang = (el, a = 5) => el.animate(
  [{ rotate: a + 'deg' }, { rotate: -a * .6 + 'deg' }, { rotate: a * .32 + 'deg' }, { rotate: -a * .14 + 'deg' }, { rotate: '0deg' }],
  { duration: a > 3 ? 1000 : 600, easing: 'cubic-bezier(.2,.7,.2,1)' });

Wire and nail. The wire is an SVG path M18 22 50 0 82 22 in a 100×22 box above the frame, preserveAspectRatio="none" with vector-effect: non-scaling-stroke, so it stays 1px on every frame width. The nail is a 6px dot at top: -26px.

Common mistakes:

  • Leaving left/top transitions on during the drag; the frame lags the hand.
  • Swinging with transform: rotate() on the same element that is positioned with transform: translate().
  • Rotating around the centre; it reads as a wobble, not a hang.
  • Grid always visible; it should only appear while carrying.
  • Forgetting to clamp frames above the sofa.
  • Scaling the wall with percentages instead of one transform; snap maths stops being in whole centimetres.

Rebuild order:

  1. Panel and stage layout; wall, sofa, lamp, floor; scale-to-fit.
  2. Seven frames with moulding, mat, print, wire and nail.
  3. Pointer drag with clamp, tilt and ghost.
  4. Snap, overlap check, hang swing.
  5. Finish picker, apply to all, snap switch, rehang all.
  6. Keyboard nudging and live status.

Details

Palette
Type
Bricolage Grotesque · Instrument Serif
Motion
Rich motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

60fps

Show one small moment at a time, and let the tags do the sorting.

All sources

On the shelves