Design Lounge
Install

Nº 055 of 520

PWA install sheet

A bottom sheet that springs up to pitch installing a PWA, with an icon, three benefits, Install and Not now. Then it shows an inline progress bar and an 'Added to Home Screen' chip.

Preview

Open full size
390 × 844 · 9.6 KB · 117 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 "PWA install sheet" (piece pwa-install-sheet) 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

231 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 custom install prompt for “Loam”, a plant-care PWA, shown as a modal bottom sheet over the app’s plant grid. It replaces the browser’s default mini-infobar: a 64px CSS-drawn app icon, name and size line, three benefits with tinted icon tiles, a plum “Install” button and a quiet “Not now”. The sheet rises with a spring (a 560ms overshoot curve); on Install, a 4px progress bar fills along the button’s bottom edge for 1.4s, the sheet drops away, a dark “Added to Home Screen” chip springs up from the bottom and a small home-screen icon pops in beside the “Show install prompt” control. The detail worth copying is the button-as-progress: no spinner, no modal swap — the CTA itself reports the install.

Reference behaviour

  1. Initial state: the sheet is already up (body.show), scrim at rgba(36,30,26,.45) over a page with a 30px serif “Loam” title and a 2×2 grid of plant cards. The Install button holds focus.
  2. Tap Install: body.busy; label changes to “Installing…”; the button’s bottom bar grows from 0 to 100% width over 1400ms cubic-bezier(.2,.7,.2,1) at 90% opacity; pointer events on the button are disabled.
  3. At 1400ms: show and busy are removed, done is added. Sheet translates to 110% over 260ms cubic-bezier(.4,0,1,1); scrim fades on the same clock. The chip (44px pill, --ink background, green check) springs from translate(-50%, 24px) and opacity 0 to rest over 560ms; a 52px home-screen icon with the label “Loam” scales from .6 to 1 in the tools row.
  4. At 4400ms: done is removed; chip and icon fade out over 260ms.
  5. Tap Not now, the scrim, or press Esc: sheet and scrim exit as in step 3 with no chip; focus returns to “Show install prompt”.
  6. Tap Show install prompt: show is added; the sheet rises from 110% to 0 over 560ms cubic-bezier(.22,1.2,.36,1) (visible overshoot of ~6px); scrim fades in over 560ms standard; the Install button takes focus after 100ms. Any pending timers from a previous install are cancelled.

Structure

390 × 844
┌────────────────────────────────────┐
│ (54px clearance)                   │
│ Loam                4 plants · 2…  │  header 62px top pad, serif 30
│ ┌──────────────┐ ┌──────────────┐  │
│ │ Water today  │ │ Water today  │  │  plant cards 150px, 20px radius
│ │ Monstera     │ │ Fiddle-leaf  │  │
│ └──────────────┘ └──────────────┘  │
│ ┌──────────────┐ ┌──────────────┐  │
│ │ Pothos       │ │ Snake plant  │  │
│ └──────────────┘ └──────────────┘  │
│ [Show install prompt]      (icon)  │  tools row, 44px ghost button
│ ░░░░░░░░░░░ scrim ░░░░░░░░░░░░░░░ │
│ ╭────────────────────────────────╮ │  sheet, 28px top radii
│ │            ────                │ │  grab 36×4
│ │ ▣ Install Loam                 │ │  icon 64 · h2 serif 24
│ │   loam.app · 1.8 MB · offline  │ │
│ │ ◆ Opens from your Home Screen  │ │  benefit tiles 40px
│ │ ◆ Watering schedule works off… │ │
│ │ ◆ Reminders when they're thir… │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │          Install           │ │ │  52px, progress bar 4px along bottom
│ │ └────────────────────────────┘ │ │
│ │           Not now              │ │  48px text button
│ │ (44px bottom padding)          │ │
│ ╰────────────────────────────────╯ │
└────────────────────────────────────┘
      done: [✓ Added to Home Screen] chip at bottom 52px
  • <header class="top"> — <h1> + count span.
  • <section class="plants" aria-label="Your plants"> — four .plant cards; a leaf drawn with ::before/::after.
  • .tools — <button class="ghost" id="again"> and the hidden .home icon preview (aria-hidden).
  • <button class="scrim" tabindex="-1" aria-label="Dismiss">.
  • <section class="sheet" role="dialog" aria-modal="true" aria-labelledby aria-describedby> — .grab, .app (icon + <h2> + <p>), <ul class="why"> of three benefits, .acts with .btn#install (label span + .bar) and .text#later.
  • .chip — role="status" aria-live="polite".

Sample content:

  • Header: “Loam” / “4 plants · 2 thirsty”.
  • Plant cards: Monstera (Living room · every 7 days, “Water today”); Fiddle-leaf fig (Hallway · every 9 days, “Water today”); Pothos (Kitchen · in 3 days); Snake plant (Bedroom · in 12 days). Each card draws a leaf: a 78px --leaf-soft teardrop (border-radius:50% 50% 50% 0, rotated −20°) at the top-right and a 4×44px --leaf stem rotated 20°.
  • Sheet: “Install Loam” / “loam.app · 1.8 MB · works offline”.
  • Benefits: “Opens from your Home Screen — Full-screen, no browser bar, launches in under a second.”; “Watering schedule works offline — Your plants and reminders are cached on this phone.”; “Reminders when they are thirsty — A nudge at 08:00 on watering days. Off by default.”
  • Buttons: “Install” → “Installing…”; “Not now”. Chip: “Added to Home Screen”. Tools: “Show install prompt”.
  • App icon drawing: 64px --plum square, 18px radius; a --leaf-soft leaf (::before, 28×44, border-radius:50% 50% 0 0 / 70% 70% 0 0, rotated −18°, bottom −8px, left 18px) with a 2px plum midrib (::after).

Tokens

:root {
  /* paper neutrals, warm */
  --paper: #f6f1e9;        /* page + sheet */
  --paper-2: #efe7db;      /* cards, hover */
  --line: #e0d6c7;         /* grab handle, ghost border */
  --ink: #241e1a;          /* text, chip background */
  --ink-2: #6b625a;        /* secondary text */
  --ink-3: #9a9086;

  /* accent — plum, plus a leaf green for the illustration */
  --plum: #5b2a4e;         /* Install button, app icon, focus ring */
  --plum-ink: #fbeef6;     /* text on plum, progress bar */
  --plum-soft: #eadbe4;    /* benefit tiles, "Water today" pill */
  --leaf: #5f7a4b;
  --leaf-soft: #e2e9d6;
  --check: #b7d59d;        /* chip check mark */

  --scrim: rgba(36, 30, 26, .45);

  /* type */
  --serif: "Fraunces", Georgia, serif;          /* opsz 9..144, wght 500/600 */
  --sans: "Instrument Sans", system-ui, sans-serif;
  --fs-h1: 30px; --fs-h2: 24px; --fs-card: 17px; --fs-cta: 16px; --fs-body: 15px; --fs-sub: 13px; --fs-tag: 11px;

  /* shape */
  --r-sheet: 28px;
  --r-icon: 18px;          /* 64px app icon */
  --r-icon-home: 14px;     /* 52px home-screen icon */
  --r-card: 20px;
  --r-btn: 14px;
  --r-tile: 12px;

  /* spacing */
  --sheet-x: 24px; --sheet-bottom: 44px; --benefit-gap: 14px;

  /* elevation */
  --shadow-sheet: 0 -12px 40px rgba(36,30,26,.18);
  --shadow-chip: 0 8px 24px rgba(36,30,26,.25);

  /* motion */
  --t-micro: 160ms;
  --t-sheet: 560ms;
  --t-exit: 260ms;
  --t-progress: 1400ms;
  --t-chip-hold: 3000ms;
  --ease-spring: cubic-bezier(.22, 1.2, .36, 1);
  --ease-std: cubic-bezier(.2, .7, .2, 1);
  --ease-exit: cubic-bezier(.4, 0, 1, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingNotes
Page titleFraunces30px6001−0.01emopsz auto
Sheet titleFraunces24px6001.10
Plant nameFraunces17px6001.20
Benefit titleInstrument Sans15px6001.350
Benefit bodyInstrument Sans13px4001.450colour --ink-2
App meta lineInstrument Sans13px400 (domain 600)1.40
Install CTAInstrument Sans16px60010
Not nowInstrument Sans15px50010colour --ink-2
ChipInstrument Sans14px50010
“Water today” pillInstrument Sans11px6001.40
Home icon labelInstrument Sans10px4001.30

Motion

ElementTriggerPropertyFrom → ToDurationEasingDelay
.sheetshowtranslateY110% → 0560ms--ease-spring0
.sheethide / donetranslateY0 → 110%260ms--ease-exit0
.scrimshowopacity0 → 1560ms--ease-std0
.scrimhideopacity1 → 0260ms--ease-exit0
.btn .barinstallwidth0 → 100%1400ms--ease-std0
.btn:activescale1 → .98160ms--ease-std0
.chipdoneopacity, translateY0, 24px → 1, 0300 / 560ms--ease-spring0 (fires at 1400ms)
.chipdone removedopacity, translateY1, 0 → 0, 24px260ms--ease-exit0 (fires at 4400ms)
.homedoneopacity, scale0, .6 → 1, 1300 / 560ms--ease-spring0

Reduced motion: all transitions 1ms. The progress bar jumps to full and the sheet/chip states still sequence on the same timers so the flow is legible.

States

  • Shown: body.show — sheet at rest, scrim interactive, Install focused.
  • Busy: body.busy — Install label “Installing…”, bar filling, button pointer-events:none.
  • Done: body.done — sheet hidden, chip and home icon visible for 3s.
  • Hidden: none of the above — sheet visibility:hidden after its exit.
  • Hover: ghost and Not now buttons take --paper-2.
  • Focus-visible: 3px --plum outline, 2px offset, on all buttons.
  • Pressed (Install): scale .98.

Accessibility

  • Sheet is role="dialog" aria-modal="true" labelled by the <h2> and described by the meta line.
  • Focus moves to Install 100ms after opening; Esc, scrim, and Not now close it and return focus to “Show install prompt”.
  • Chip is role="status" aria-live="polite" so “Added to Home Screen” is announced once.
  • The Install label change (“Installing…”) is inside the button, so the busy state is announced on re-read; add aria-busy="true" on the button if the framework supports it.
  • Decorative icon and leaf shapes are aria-hidden.
  • Contrast: --ink-2 on --paper 5.6:1; --plum-ink on --plum 10.9:1; --plum on --plum-soft 7.2:1.
  • Hit targets: Install 52px, Not now 48px, ghost 44px, chip 44px.

Responsive rules

  • 390 wide: as specified.
  • 360 wide: sheet padding 20px; benefit body text may wrap to three lines — allow it, don’t clamp.
  • ≥ 600 wide: sheet becomes a centred card 420px wide, 28px radius on all corners, bottom:32px; the chip stays bottom-centre.
  • Short viewports (< 700px tall): the plant grid is hidden behind the sheet anyway; make the sheet max-height:calc(100% - 54px) and scroll its benefits.

Acceptance checklist

  • Sheet has 28px top radii, 24px side padding, 44px bottom padding and casts 0 -12px 40px rgba(36,30,26,.18).
  • Sheet enters over 560ms with cubic-bezier(.22,1.2,.36,1) and visibly overshoots; exits over 260ms with cubic-bezier(.4,0,1,1).
  • App icon is 64px, 18px radius, #5B2A4E, drawn in CSS (no image).
  • Three benefits each have a 40px #EADBE4 tile with a plum icon, a 15px/600 title and 13px #6B625A body.
  • Install button is 52px, #5B2A4E, and shows a 4px progress bar along its bottom edge filling over 1400ms.
  • After the bar fills, the sheet drops and a 44px #241E1A chip reading “Added to Home Screen” springs in at bottom 52px.
  • Chip and home icon disappear 3s later.
  • Not now, scrim and Esc dismiss without the chip; focus returns to the trigger.
  • “Show install prompt” re-opens the sheet and cancels stale timers.
  • Dialog semantics present: role="dialog" aria-modal="true" aria-labelledby.
  • Focus rings are visible on every button.
  • Reduced motion collapses all transitions to 1ms.

Implementation notes

Spring via a single overshoot curve. No JS physics needed; a cubic-bezier with y > 1 gives a ~6px overshoot at 560ms:

.sheet { transform:translateY(110%); visibility:hidden;
  transition: transform 260ms var(--ease-exit), visibility 0s linear 260ms; }
.show .sheet { transform:none; visibility:visible;
  transition: transform 560ms cubic-bezier(.22,1.2,.36,1), visibility 0s; }

Progress inside the button. The bar is a child of the CTA; overflow:hidden on the button clips it to the radius:

.btn { position:relative; overflow:hidden; border-radius:14px; }
.btn .bar { position:absolute; left:0; bottom:0; height:4px; width:0; opacity:0; background:var(--plum-ink); }
.busy .btn .bar { opacity:.9; width:100%; transition: width 1400ms var(--ease-std); }

Timer hygiene — cancel both timers on re-open so a fast Not now → Show → Install cannot leave the chip stuck:

function open() {
  clearTimeout(t1); clearTimeout(t2);
  body.classList.remove('done', 'busy'); lbl.textContent = 'Install';
  body.classList.add('show'); setTimeout(() => install.focus(), 100);
}

Common mistakes: using the standard curve for the sheet (no spring, feels like a slide); hiding the sheet with display:none (kills the exit); swapping the CTA for a spinner (the brief’s point is that the button itself is the progress indicator).

Details

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

Studied

Stripe

A control. It studies this site for quiet, not for a show.

All sources