Design Lounge
Install

Nº 361 of 520

Polaroid frames, three ways

One 300px instant-photo frame in three treatments: a hard shadow lit by the cursor, tape with a peelable sticky note, and a print you shake to develop.

Preview

Open full size
1280 × 800 · 14.9 KB · 246 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 "Polaroid frames, three ways" (piece card-polaroid-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

230 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 contact sheet with one instant-photo frame shown three ways, side by side on a dotted off-white board. A: a black-bordered frame with a solid 10px hard shadow that always falls away from the cursor, as if the pointer were the lamp. B: a softer frame held up by a strip of tomato washi tape, with a yellow sticky note slapped on its corner that you click to peel up, showing a date written underneath. C: a print still developing. It starts milky and blurred at 35%, and you drag it side to side to shake it. As you shake, the haze lifts and the colour comes back, and at 100% the caption writes itself on in marker. Each frame is a 300px card with 16px borders and a 68px marker caption strip. The detail worth copying is that the three treatments share one frame component and differ only in a modifier class.

Reference behaviour

  1. First frame: three cells in a row, 300px each, 72px gaps, vertically centred under a header rule. Labels under each: “A Hard shadow / light follows you”, “B Tape + note / click to peel”, “C Developing / drag to shake”. C shows a 35% meter and an “Eject new” button below its label.
  2. A, pointer anywhere on the page (mouse only): the shadow direction is the vector from the pointer to the frame centre. Its length is min(14, 6 + distance / 60) px. Updates run once per animation frame; the box-shadow eases over 140ms.
  3. A, keyboard: focus the frame, Arrow keys move the shadow 2px per press (Left moves it left, as if the light moved right), clamped to ±14px. Home resets it to 10px, 10px.
  4. A, pressed (:active): the frame moves 60% of the shadow offset toward the shadow and the shadow shrinks to 40%, so it reads as pressed into the board.
  5. B, hover the sticky note: it straightens from 7° to 4° and rises 3px.
  6. B, click or Enter/Space on the note: it peels up to rotate(-10deg) translate(8px, -150px) with a springy 380ms ease, the shadow grows from 5px to 9px 12px, the small label changes “peel me” → “stick back”, and the date “08 · 07 · 26” written on the frame is now visible. Click again to stick it back.
  7. C, drag: the frame follows the hand by half the accumulated x travel (clamped ±40px, so ±20px of movement), tilts 0.12° per px, and every pixel of movement adds (|dx| + |dy| × 0.5) / 3600 to the development. Release springs it back over 420ms.
  8. C, keyboard: Space or Enter adds 0.10 and plays a 360ms side-to-side nudge.
  9. C at 100%: the caption “Gran’s tomatoes, again” reveals left to right over 900ms by clip-path. The meter fills black. “Eject new” resets development to 0.
  10. Reduced motion: no transitions, no nudge keyframes, C does not move while shaken but still develops.

Structure

1280 × 800, body padding 36px 48px 40px, dotted board (22px grid of 1px dots)
CONTACT SHEET 03 — POLAROID FRAMES          Three treatments for one 300px frame. Move, peel, shake.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2px rule
     ┌───────────┐▌        ▒▒tape▒▒         ┌───────────┐
     │  desert   │▌      ┌───────────┐      │  milky    │
     │  road     │▌      │ lake +    │      │  tomato   │
     │  268×268  │▌      │ red coat  │      │  vines    │
     │           │▌      │       ┌────────┐ │           │
     │Route 9,   │▌      │Juno at│ sticky │ │ (caption  │
     └───────────┘▌      └───────│ 152px  │ └───────────┘
      ▀▀▀▀▀▀▀▀▀▀▀▀              └────────┘
   [A] Hard shadow       [B] Tape + note       [C] Developing
                                               ▬▬▬▬▬▬  35%  [EJECT NEW]
   rotate −3°            rotate 2.5°           rotate −1.5°
  • header with an h1 (mono caps) and a one-line p, 2px ink bottom rule.
  • main.sheet: grid repeat(3, minmax(0, 300px)), gap 72px, centred both ways.
  • Each .cell: grid, gap 34px. A figure.pol and a p.label with a 24px boxed letter.
  • .pol: background: --paper, 2px ink border, padding: 16px 16px 0. Inside: .img (aspect 1, 2px ink border, an inline SVG scene) and figcaption.cap (68px tall).
  • A: .pol.hard, tabindex="0", role="group".
  • B: .pol.taped with span.tape (absolute, top −17px, 116 × 34, centred, rotate −5°), span.under (the date, bottom right of the frame), and button.sticky (absolute, right −42px, bottom −34px, 152px wide, min-height 132px).
  • C: .pol.dev, tabindex="0", role="group", --p custom property. .meter sits absolutely 14px under the label, so the three labels align on one baseline.

Image scenes (inline SVG, viewBox="0 0 100 100", preserveAspectRatio="xMidYMid slice"): A is a desert road at sunrise (peach sky, cream sun, rust mesas, dark road with a yellow dashed line). B is a person in a tomato coat seen from behind at a blue lake. C is tomato vines (greens with five red tomatoes and highlights). No photos.

Tokens

:root {
  --bg: #f1eee4;        /* board */
  --paper: #fffdf6;     /* frame */
  --ink: #141414;       /* borders, hard shadows, text */
  --ink-2: #4a4740;     /* secondary text */
  --line: #d8d3c4;
  --tomato: #ff4f2e;    /* accent: focus ring, tape, A caption */
  --sticky: #ffd93b;    /* sticky note */
  --film: #d9dccf;      /* undeveloped haze */

  --marker: "Permanent Marker", "Marker Felt", cursive;
  --mono: "Sometype Mono", ui-monospace, monospace;

  --frame-w: 300px; --frame-pad: 16px; --cap-h: 68px; --border: 2px;
  --shadow-hard: 10px;       /* default offset, both axes */
  --shadow-max: 14px;
  --dots: radial-gradient(rgba(20,20,20,.13) 1px, transparent 1.2px) 0 0 / 22px 22px;

  --ease: cubic-bezier(.2, .7, .2, 1);
  --spring: cubic-bezier(.34, 1.56, .64, 1);
  --t-shadow: 140ms; --t-peel: 380ms; --t-return: 420ms; --t-caption: 900ms; --t-nudge: 360ms;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Header titleSometype Mono13px60010.16emUPPER
Header lineSometype Mono12px4001.50.04emsentence, --ink-2
CaptionPermanent Marker24px4001.10as written, rotate −1.5°
A caption colour—————--tomato
Sticky notePermanent Marker17px4001.20as written
Sticky labelSometype Mono10px60010.12emUPPER
Under-note datePermanent Marker14px40010--ink-2, rotate −3°
Cell labelSometype Mono12px400/6001.50.08emUPPER, tail --ink-2
Meter valueSometype Mono13px6001.50—
ButtonSometype Mono11px40010.1emUPPER

Marker is for handwriting on the objects only. Every UI label is mono.

Motion

ElementTriggerPropertyFrom → ToDurationEasingReduced motion
A shadowpointer move (rAF)box-shadow offsetcurrent → away from pointer, 6–14px140ms--easeinstant
A frame:activetranslate, shadow→ 60% toward shadow, shadow 40%160ms--easeinstant
Stickyhovertransformrotate 7° → 4°, −3px380ms--springinstant
Stickyclicktransform, shadow→ rotate −10°, (8px, −150px), shadow 9px 12px380ms--springinstant
C framedragtranslate, rotatefollows hand, ±20px, 0.12°/pxnone—stays still
C framereleasetransform→ rotate −1.5°420ms--springinstant
C frameSpace / Enterkeyframes−8px/−4° → +7px/1.5° → rest360ms--easenone
C image--p changesblur, saturate, contrast, haze opacitysee formulalive—same
C caption--p reaches 1clip-path: inset(0 100% 0 0 → 0)hidden → shown900ms--easeinstant

Development formula, all from one custom property --p (0–1):

.dev .img svg { filter: blur(calc((1 - var(--p)) * 7px)) saturate(calc(var(--p) * 1.1)) contrast(calc(.55 + var(--p) * .45)); }
.dev .img::after { background: var(--film); opacity: calc((1 - var(--p)) * .94); }

States

  • A resting: shadow 10px, 10px; frame rotate −3°; cursor crosshair.
  • A focus-visible: 2px tomato outline, 4px offset.
  • Sticky resting: yellow, 2px ink border, 5px 5px hard shadow, folded corner (22px triangle, darker yellow #e6bf1f). aria-pressed="false".
  • Sticky peeled: aria-pressed="true", label “stick back”, larger shadow.
  • C developing: cursor grab; while dragging .shaking, cursor grabbing, transition off.
  • C done (.done): caption visible; the haze is gone.
  • Meter: 10px tall, 2px ink border, black fill to --p × 100%. “Eject new” button: 40px tall, 3px hard shadow, presses flat on :active.
  • No disabled, loading, or error states.

Accessibility

  • A: figure with role="group" and aria-label="Hard shadow polaroid. Arrow keys move the light."; arrows call preventDefault().
  • B: the sticky note is a real button with aria-pressed and a label that contains its text: “Sticky note: the water was freezing, she went in twice. Press to peel it up.”
  • C: role="group", label updated with the percentage: “Developing polaroid, 35 percent developed. Drag it side to side, or press Space, to shake it.” The label text under it is linked with aria-describedby. The percentage is an output element.
  • One polite live region announces: each 25% step, “Developed: Gran’s tomatoes, again.”, the peel state, and “New shot ejected. Blank film.”
  • Image SVGs are aria-hidden; captions are real figcaption text.
  • Contrast: --ink on --paper 18:1; --ink-2 on --bg 8:1; tomato caption on paper is large display text (24px) at 3.2:1, used decoratively next to the same words as the label.
  • Hit targets: frames 300px, sticky 152 × 132, button 40px tall.

Responsive rules

  • ≥ 1280: three 300px columns, 72px gaps.
  • 1024 (861–1100): columns minmax(0, 260px), gap 48px, captions 20px.
  • 768 and < 860: one column of 300px frames, 72px row gap, header stacks (title over line), body padding 28px 20px. The sticky moves in to right: -12px so it stays on screen.
  • 375: same single column; nothing overflows. The page scrolls vertically.
  • Pointer-light on A is mouse only; on touch, the shadow stays at its last value. Shaking works with touch because C has touch-action: none.

Acceptance checklist

Always

  • One frame component (16px borders, square image, 68px caption strip, 2px ink border) used three times with modifier classes.
  • Hard shadow is a zero-blur box-shadow whose offset points away from the cursor, length 6–14px, updated once per frame.
  • Arrow keys move the hard shadow; Home resets it.
  • Tape is a semi-transparent striped strip with zig-zag ends from clip-path, using mix-blend-mode: multiply.
  • Sticky note is a button with aria-pressed; peeling reveals something underneath.
  • Development is a single --p custom property driving blur, saturation, contrast and haze opacity.
  • Shaking by drag and by Space both develop the print; the caption reveals at 100%.
  • Labels under all three cells share one baseline.
  • Reduced motion keeps every interaction but removes movement.

This demo

  • Board #f1eee4 with a 22px dot grid; frames #fffdf6; ink #141414.
  • Captions “Route 9, 6:40am” (tomato), “Juno at Kettle Pond”, “Gran’s tomatoes, again”.
  • Sticky reads “the water was freezing. she went in twice.” and the date under it is “08 · 07 · 26”.
  • C starts at 35%; a drag of about 3600px of travel takes it to 100%.
  • Frames rotate −3°, 2.5°, −1.5°.

Implementation notes

Light from the cursor. The shadow is the unit vector from pointer to frame centre, scaled:

addEventListener('pointermove', e => {
  if (e.pointerType !== 'mouse') return;
  px = e.clientX; py = e.clientY;
  if (raf) return;
  raf = requestAnimationFrame(() => {
    raf = 0;
    const r = hard.getBoundingClientRect();
    const dx = r.left + r.width / 2 - px, dy = r.top + r.height / 2 - py;
    const d = Math.hypot(dx, dy) || 1, len = Math.min(14, 6 + d / 60);
    hard.style.setProperty('--sx', (dx / d * len).toFixed(1) + 'px');
    hard.style.setProperty('--sy', (dy / d * len).toFixed(1) + 'px');
  });
});

Shake to develop. Distance travelled, not speed, drives progress, so a slow wobble still works:

dev.addEventListener('pointermove', e => {
  if (!down) return;
  const dx = e.clientX - lx, dy = e.clientY - ly; lx = e.clientX; ly = e.clientY;
  ox = clamp(ox + dx, 40);
  if (!reduce) dev.style.transform = `translate(${ox * .5}px, ${clamp(dy, 6)}px) rotate(${-1.5 + ox * .12}deg)`;
  setP(p + (Math.abs(dx) + Math.abs(dy) * .5) / 3600);
});

Washi tape ends.

.tape { background: repeating-linear-gradient(135deg, rgba(255,79,46,.62) 0 7px, rgba(255,79,46,.42) 7px 14px);
  clip-path: polygon(0 8%,4% 0,8% 10%,12% 2%,88% 2%,92% 10%,96% 0,100% 8%,100% 92%,96% 100%,92% 90%,88% 98%,12% 98%,8% 90%,4% 100%,0 92%);
  mix-blend-mode: multiply; }

Common mistakes:

  • A blurred drop shadow on A. The point is a hard, zero-blur offset.
  • Animating filter per frame with JS. Set --p and let CSS compute every filter value.
  • Putting the meter in the label’s grid row, which pushes C’s label below A and B. Position it absolutely.
  • Sticky note as a div with a click handler. It must be a button.
  • Starting C at 0%. The first frame should hint at the picture, so start at 35%.
  • Real photos. Every image is a few flat SVG shapes.

Rebuild order:

  1. Board, header, three-cell grid.
  2. One frame component with an SVG scene and caption.
  3. A: hard shadow variables, pointer and keys.
  4. B: tape, sticky button, peel state, the date under it.
  5. C: --p filters and haze, drag to shake, Space nudge, caption reveal, meter, reset.
  6. Live region, labels, reduced motion, single-column layout.

Details

Palette
Type
Permanent Marker · Sometype Mono
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Bermawy

Let one toy be the real control. Keep the rest plain and on paper.

All sources

On the shelves