Design Lounge
Install

Nº 086 of 520

Shared element expand

Six project cards; clicking one FLIP-animates it (transform + border-radius, 420ms expo-out) into a full detail dialog while the rest fade, and Esc reverses it.

Preview

Open full size
1280 × 800 · 12.8 KB · 145 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 "Shared element expand" (piece shared-element-expand) 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

218 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 3 × 2 grid of project cards for an architecture studio’s portfolio page. Clicking a card does not open a new panel: the card itself appears to grow into a 1040 × 688 detail dialog using the FLIP technique (First, Last, Invert, Play) — one Web Animations call on transform and border-radius, 420ms, expo-out. The other five cards and the page header fade to 18 % / 35 % so the expanded element owns the frame. Esc, the close button, or a click outside reverses the same animation back into the grid slot. The palette is warm paper with one terracotta accent; titles are a light-weight serif. The detail worth copying is that the reverse animation is computed from the live rects, so it always lands exactly on the card even if the page has resized.

Reference behaviour

  1. Initial state: header (72px) with brand, four nav links (“Projects” is current), and a right-aligned meta line. Below, an h1 “Houses we finished” with a 380px-wide lede, then six cards in a 3-column grid with 20px gaps. Each card: a 172px flat colour swatch with an uppercase tag top-left, then a serif title (21px) and a “location · year” caption.
  2. Hover a card: it lifts 2px (translateY(-2px)) and gains a soft shadow, 160ms.
  3. Click a card: the hidden detail dialog is filled with that card’s data and its --c swatch colour, then shown at its final fixed position (inset: 56px 120px). The dialog is immediately transformed to overlay the card’s rect (translate + non-uniform scale, radius corrected so it still reads as 14px on screen) and animates to identity over 420ms cubic-bezier(.16,1,.3,1). The source card becomes visibility: hidden for the duration.
  4. While the dialog grows: the other five cards fade to opacity 0.18 and the header and lede to 0.35 (320ms). The dialog’s body copy (paragraphs, facts list, CTA) fades in over 260ms after a 200ms delay, so text never appears stretched mid-scale.
  5. When the open animation finishes, focus moves to the round close button in the hero’s top-right.
  6. Press Esc, click the close button, or click outside the dialog: the body copy fades out in 120ms, the fade-outs on the grid reverse, and the dialog animates from identity back to the card’s current rect over 420ms with the same easing. On finish the dialog is hidden, the source card is made visible again and refocused.
  7. Clicks during an in-flight animation are ignored (a guard flag), so double-clicks cannot leave the UI half-open.
  8. With prefers-reduced-motion: reduce, all durations are 1ms: the dialog appears and disappears in place.

Structure

1280 × 800
┌──────────────────────────────────────────────────────────────────────┐
│ header 72  ⌂ Halden Studio   Projects Studio Journal Contact   meta  │
├──────────────────────────────────────────────────────────────────────┤
│ 28px                                                                 │
│ h1 Houses we finished                        lede (380px) ─────────  │
│ 22px                                                                 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐  x=48, 3 cols    │
│ │ swatch 172   │ │ swatch 172   │ │ swatch 172   │  gap 20           │
│ │ title / cap  │ │ title / cap  │ │ title / cap  │  card ≈ 381×254   │
│ └──────────────┘ └──────────────┘ └──────────────┘                   │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐                   │
│ │              │ │              │ │              │                   │
│ └──────────────┘ └──────────────┘ └──────────────┘                   │
│ hint: Click a house to expand · Esc to close                         │
└──────────────────────────────────────────────────────────────────────┘
        dialog when open: fixed, inset 56px 120px → 1040 × 688
        ┌───────────────────────────────────────────┐
        │ hero 300  (swatch colour, tag, 46px title)  ⊗ close 40 │
        │ body: 1.4fr copy │ 1fr <dl> facts + CTA     │
        └───────────────────────────────────────────┘
  • <header> — .brand (22px SVG house glyph + serif name), <nav aria-label="Primary"> of <a>, .meta-line pushed right with margin-left: auto.
  • <main> — .lede (flex, justify-content: space-between) then .grid.
  • .grid — <button class="card" style="--c:#…" data-tag data-loc data-year data-area data-mat data-lede data-body>; inside .swatch > b and .meta > h2 + span. All copy for the dialog lives on the card’s data-* attributes.
  • <section class="detail" role="dialog" aria-modal="true" aria-labelledby="dtitle" hidden> — .hero (tag, h2#dtitle, .close button) then .body (copy column + <dl> with four pairs and a .cta button).
  • .hint — fixed bottom-left helper text with <kbd>Esc</kbd>.

Tokens

:root {
  /* colour — warm paper, one terracotta accent */
  --bg: #f6f1e8;          /* page */
  --card: #fffbf4;        /* card + dialog surface */
  --line: #e6dccb;        /* hairlines, card border */
  --ink: #221d18;         /* primary text */
  --ink-2: #6b5f52;       /* secondary text, nav */
  --ink-3: #9a8d7d;       /* meta, hints, dt labels */
  --accent: #c4552d;      /* h1 emphasis, focus rings, CTA, brand glyph */
  --accent-ink: #fff6ef;  /* text on accent */
  /* per-card swatch colours (set inline as --c) */
  --c-coastal: #b8623f; --c-forest: #6e7d5a; --c-courtyard: #c99a4a;
  --c-alpine: #7a6b8f;  --c-lake: #4f7a86;   --c-urban: #a04a3e;

  /* type */
  --serif: "Fraunces", Georgia, serif;
  --sans: "Instrument Sans", system-ui, sans-serif;

  /* layout */
  --gap: 20px;
  --pad-x: 48px;
  --header-h: 72px;
  --swatch-h: 172px;
  --hero-h: 300px;
  --dialog-inset: 56px 120px;
  --r-card: 14px;
  --r-detail: 20px;
  --r-close: 50%;
  --shadow: 0 24px 60px -20px rgba(60,40,20,.35), 0 1px 0 rgba(60,40,20,.06);
  --shadow-hover: 0 10px 24px -14px rgba(60,40,20,.4);

  /* motion */
  --t-flip: 420ms;
  --t-fade: 320ms;
  --t-body-in: 260ms;  --t-body-delay: 200ms;  --t-body-out: 120ms;
  --t-micro: 160ms;
  --ease-out: cubic-bezier(.16, 1, .3, 1);   /* the FLIP */
  --ease: cubic-bezier(.2, .7, .2, 1);       /* everything else */
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
BodyInstrument Sans14px4001.50sentence
BrandFraunces20px5001−0.01emsentence
Nav linkInstrument Sans14px5001.50sentence
Page h1Fraunces34px300 (em 500 italic)1.05−0.02emsentence
LedeInstrument Sans13.5px4001.50sentence
Swatch tagInstrument Sans11px5001+0.08emUPPERCASE
Card titleFraunces21px5001.15−0.015emsentence
Card captionInstrument Sans13px4001.50sentence
Dialog titleFraunces46px3001−0.025emsentence
Dialog ledeFraunces22px3001.35−0.01emsentence
Dialog bodyInstrument Sans15px4001.60sentence
dt labelInstrument Sans11px6001.5+0.04emUPPERCASE
CTAInstrument Sans13px60010sentence
HintInstrument Sans12px4001.5+0.02emsentence

Use the Fraunces variable axes opsz 9..144, wght 300 & 500 from Google Fonts; Instrument Sans at 400/500/600.

Motion

ElementTriggerPropertyFrom → ToDurationEasingDelay
.detail (open)card clicktransform, border-radiuscard rect (translate + scale, radius 14/s) → none, 20px420ms--ease-out0
.detail (close)Esc / closetransform, border-radiusnone, 20px → card rect420ms--ease-out0
.card:not(.src)open / closeopacity1 ↔ 0.18320ms--ease0
header, .ledeopen / closeopacity1 ↔ 0.35320ms--ease0
.detail .body (in)openopacity0 → 1260ms--ease200ms
.detail .body (out)closeopacity1 → 0120ms--ease0
.card hoverhovertransform, box-shadownone → translateY(−2px), --shadow-hover160ms--ease0
.close hoverhoverbackgroundrgba(255,255,255,.9) → #fff160ms--ease0

Reduced motion: * { transition-duration: 1ms !important; animation-duration: 1ms !important } and pass duration: 1 to the Web Animations call. The state machine (hidden → open → hidden) is unchanged.

States

  • Card hover: lifts 2px, shadow --shadow-hover. Cursor pointer.
  • Card focus-visible: outline: 2px solid var(--accent); outline-offset: 3px.
  • Card .src (its dialog is open): visibility: hidden — keeps its grid slot so the layout does not reflow.
  • Card dimmed (another card open): opacity 0.18, no pointer feedback needed because the outside-click handler closes the dialog first.
  • Nav current: aria-current="page", ink colour, box-shadow: inset 0 -2px 0 var(--accent).
  • Dialog open: body.is-open; dialog hidden removed; aria-modal="true".
  • Close button: 40px circle, rgba(255,255,255,.9) on the hero colour; hover #fff; focus-visible accent ring.
  • CTA: pill, --accent background, --accent-ink text; focus-visible accent ring offset 3px.
  • Animating: a module-level anim reference is non-null; clicks and Esc are ignored until onfinish.

Accessibility

  • Cards are <button> elements (not divs with click handlers), so Enter/Space open them and they appear in the tab order.
  • The dialog is role="dialog" aria-modal="true" aria-labelledby="dtitle". It is hidden when closed, so it is out of the accessibility tree.
  • On open-finish, focus moves to the close button; on close-finish, focus returns to the originating card.
  • Esc closes from anywhere in the document. Tab inside the dialog cycles close button → CTA; add a focus trap if your framework has one, otherwise the outside-click handler and Esc are sufficient for this piece.
  • Contrast: --ink-2 on --card is 5.6:1; --ink-3 is used only at ≥ 11px for meta and hints. Hero text is white over a bottom gradient rgba(0,0,0,.42) on every swatch colour — verify ≥ 4.5:1 for the tag (85 % opacity) on the lightest swatch (#c99a4a).
  • Hit targets: cards ≈ 381 × 254px; close button 40 × 40px; nav links have 6px vertical padding.

Responsive rules

  • ≥ 1280: as specified (inset: 56px 120px dialog; 3-column grid).
  • 1024–1279: dialog inset: 40px 64px; grid stays 3 columns; swatch height 148px.
  • 768–1023: grid 2 columns; dialog inset: 32px; hero 240px; dialog body becomes one column (copy above facts).
  • < 640: grid 1 column; dialog inset: 0 with border-radius: 0 as the “last” state (the FLIP still works — the radius keyframe just ends at 0); hero 200px; hint hidden.

Acceptance checklist

  • Grid is 3 × 2 with 20px gaps and 48px side padding at 1280px wide; card swatches are 172px tall.
  • Clicking a card animates the dialog from the card’s exact rect to inset: 56px 120px over 420ms with cubic-bezier(.16,1,.3,1).
  • The animated keyframe uses transform: translate() scale() with transform-origin: 0 0 and a corrected border-radius (14px ÷ scale) so corners look constant.
  • The source card is visibility: hidden (not display: none) while its dialog is open; the grid does not reflow.
  • Other cards fade to 0.18 and header/lede to 0.35 over 320ms in both directions.
  • Dialog body copy fades in after a 200ms delay and never shows scaled/stretched text.
  • Esc, the close button, and clicking outside all reverse the animation into the card’s current rect (resize the window while open and confirm it still lands).
  • Focus goes to the close button on open-finish and back to the card on close-finish.
  • Repeated clicks during the 420ms are ignored; the UI never gets stuck half-open.
  • Cards, nav links, close and CTA all show a 2px accent focus ring with 3px offset.
  • prefers-reduced-motion: reduce collapses every duration to 1ms with no missing state.
  • No images are used; each card’s colour comes from an inline --c custom property that the dialog copies.

Implementation notes

FLIP with one Web Animations call. Measure the card (first), show the dialog at its final CSS position and measure it (last), then animate from the inverted delta to identity. Keep transform-origin: 0 0 so translate and scale compose from the top-left.

function frames(from, to) {
  const sx = from.width / to.width, sy = from.height / to.height;
  return [
    { transform: `translate(${from.left - to.left}px, ${from.top - to.top}px) scale(${sx}, ${sy})`,
      borderRadius: `${14 / sx}px / ${14 / sy}px` },   // 14px on screen while scaled
    { transform: 'none', borderRadius: '20px' }
  ];
}
const first = card.getBoundingClientRect();
detail.hidden = false; card.classList.add('src'); document.body.classList.add('is-open');
const last = detail.getBoundingClientRect();
anim = detail.animate(frames(first, last), { duration: 420, easing: 'cubic-bezier(.16,1,.3,1)', fill: 'forwards' });
anim.onfinish = () => { anim = null; closeBtn.focus(); };

Reverse from live rects, not stored ones. On close, re-measure both boxes and play the same keyframes reversed, then hide only in onfinish:

const last = detail.getBoundingClientRect(), first = src.getBoundingClientRect();
document.body.classList.remove('is-open');
anim = detail.animate(frames(first, last).reverse(), { duration: 420, easing: EASE, fill: 'forwards' });
anim.onfinish = () => { detail.hidden = true; src.classList.remove('src'); src.focus(); src = anim = null; };

Mask the non-uniform scale. The scale is ~2.7 × 2.7 but not equal on both axes, so text inside would look stretched for the first ~150ms. Keep only the hero colour and title in the dialog during the flip and fade the body in late:

.detail .body { opacity: 0; transition: opacity 120ms var(--ease); }
.is-open .body { opacity: 1; transition: opacity 260ms var(--ease) 200ms; }

Common mistakes: measuring the dialog while it is still display: none (rect is 0 × 0 → division by zero); forgetting fill: 'forwards' so the dialog snaps to identity before onfinish; using display: none on the source card so the grid reflows and the return target moves; leaving the is-open class on <body> when the close animation is interrupted.

Details

Palette
Type
Fraunces · Instrument Sans
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

60fps

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

All sources