Nº 036 of 520
Mobile landing with sticky CTA
A 390px marketing landing page: Fraunces headline, scroll-snap feature carousel, social-proof block, FAQ accordion and a CTA bar that slides up once the hero scrolls away.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Mobile landing with sticky CTA" (piece mobile-landing-sticky-cta) 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.
<!-- Design Lounge Nº 036 · "Mobile landing with sticky CTA" · designlounge.vercel.app -->
# Mobile landing with sticky CTA
> **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 single-scroll mobile marketing page for a fictional savings product ("Loam"). It stacks a sticky translucent header, a serif hero with the rate as the proof point, a horizontally scroll-snapping carousel of four feature cards, a social-proof block, a four-item FAQ accordion and a legal footer. The detail worth copying: a fixed conversion bar (rate + button) that stays hidden while the hero's own button is on screen and slides up from the bottom the moment the hero scrolls out, so the page never shows two primary buttons at once. Warm cream paper, one deep green accent, no shadows except under the bar.
## Reference behaviour
1. Initial state: header at the top (below the 54px status-bar reserve), hero visible with eyebrow, 44px headline, lede, two buttons and the rate row. No bottom bar is visible.
2. Scroll down 1px or more: the header gains a 1px `--line` bottom border (it stays sticky at `top:0`, translucent with a 10px blur).
3. Keep scrolling until the hero's bottom edge passes under the header: the bottom bar slides up from `translateY(100% + 80px)` to `translateY(0)` over 320ms `--ease-out`. Its `aria-hidden` flips to `false`.
4. Scroll back so any part of the hero is visible again: the bar slides down and `aria-hidden` returns to `true`. This is the replay.
5. Swipe the feature carousel: cards are 300px wide with a 12px gap and snap to the left gutter (`scroll-snap-align: start`). Four dot indicators below update as the nearest card changes; the current dot stretches from a 6px circle to an 18×6 pill in `--accent`.
6. Tap a dot: the carousel scrolls smoothly to that card.
7. Tap an FAQ question: its answer appears with a 4px rise + fade over 320ms; the plus icon rotates 45° into a cross and turns `--accent`. Opening one question closes any other open question.
8. Tap either "Open an account" button: no navigation in the demo (href `#`), but it is the only primary action on the page.
## Structure
```
390 × 844 (54px status reserve above, 80px browser bar below)
┌──────────────────────────────────────┐
│ header 56 ─ ● Loam Sign in │ sticky, blur
├──────────────────────────────────────┤
│ SAVINGS, DONE PROPERLY │ eyebrow
│ Money that grows while you │ h1 44/1.02 Fraunces
│ get on with life. │
│ lede 17px (≤ 32ch) │
│ [ Open an account ] [See how it works]│ 52px pills, 2-col grid
│ ─────────────────────────────── │
│ 4.35% AER variable · paid monthly │
├──────────────────────────────────────┤
│ h2 Built for people who forget… │
│ ┌───────────┐ ┌───────────┐ ┌── │ carousel: 300px cards, 12 gap
│ │ card 220+ │ │ card │ │ │ snap start, 20px gutter
│ └───────────┘ └───────────┘ └── │
… 199 more lines. Copy or download for the full brief. Loading the source…Brief
239 linesBuild 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 single-scroll mobile marketing page for a fictional savings product (“Loam”). It stacks a sticky translucent header, a serif hero with the rate as the proof point, a horizontally scroll-snapping carousel of four feature cards, a social-proof block, a four-item FAQ accordion and a legal footer. The detail worth copying: a fixed conversion bar (rate + button) that stays hidden while the hero’s own button is on screen and slides up from the bottom the moment the hero scrolls out, so the page never shows two primary buttons at once. Warm cream paper, one deep green accent, no shadows except under the bar.
Reference behaviour
- Initial state: header at the top (below the 54px status-bar reserve), hero visible with eyebrow, 44px headline, lede, two buttons and the rate row. No bottom bar is visible.
- Scroll down 1px or more: the header gains a 1px
--linebottom border (it stays sticky attop:0, translucent with a 10px blur). - Keep scrolling until the hero’s bottom edge passes under the header: the bottom bar slides up from
translateY(100% + 80px)totranslateY(0)over 320ms--ease-out. Itsaria-hiddenflips tofalse. - Scroll back so any part of the hero is visible again: the bar slides down and
aria-hiddenreturns totrue. This is the replay. - Swipe the feature carousel: cards are 300px wide with a 12px gap and snap to the left gutter (
scroll-snap-align: start). Four dot indicators below update as the nearest card changes; the current dot stretches from a 6px circle to an 18×6 pill in--accent. - Tap a dot: the carousel scrolls smoothly to that card.
- Tap an FAQ question: its answer appears with a 4px rise + fade over 320ms; the plus icon rotates 45° into a cross and turns
--accent. Opening one question closes any other open question. - Tap either “Open an account” button: no navigation in the demo (href
#), but it is the only primary action on the page.
Structure
390 × 844 (54px status reserve above, 80px browser bar below)
┌──────────────────────────────────────┐
│ header 56 ─ ● Loam Sign in │ sticky, blur
├──────────────────────────────────────┤
│ SAVINGS, DONE PROPERLY │ eyebrow
│ Money that grows while you │ h1 44/1.02 Fraunces
│ get on with life. │
│ lede 17px (≤ 32ch) │
│ [ Open an account ] [See how it works]│ 52px pills, 2-col grid
│ ─────────────────────────────── │
│ 4.35% AER variable · paid monthly │
├──────────────────────────────────────┤
│ h2 Built for people who forget… │
│ ┌───────────┐ ┌───────────┐ ┌── │ carousel: 300px cards, 12 gap
│ │ card 220+ │ │ card │ │ │ snap start, 20px gutter
│ └───────────┘ └───────────┘ └── │
│ ▬ · · · │ dots
├──────────────────────────────────────┤
│ ┌ proof (surface-2, r20) ──────────┐ │
│ │ 5 stars 4.8 from 21,400 reviews │ │
│ │ The Ledger Weekend Money … │ │
│ └──────────────────────────────────┘ │
├──────────────────────────────────────┤
│ h2 Questions, answered │
│ ─ Is the 4.35% rate fixed? + │ details rows, 56px min
│ ─ Can I withdraw whenever… + │
│ … │
│ footer (legal, 12px) │
├──────────────────────────────────────┤
│ ▲ bar: 4.35% AER [Open an account] │ fixed, bottom: 80px
└──────────────────────────────────────┘
<header class="top">—position: sticky; top: 0,margin-top: 54pxso it clears the status reserve. Contains the wordmark and a “Sign in” link.<main>→<section class="hero">(eyebrow<p>,<h1>,.lede,.row— agrid-template-columns: 1fr 1fr; gap: 10pxgrid of two<a class="btn">(52px,padding: 0 16px,white-space: nowrap) — and.rate).<section>features:<h2>,.sub,<div class="carousel" tabindex="0" aria-roledescription="carousel">of four<article class="card">, then<div class="dots" role="tablist">populated by JS.<section class="proof">— star row (five inline SVG stars, rating, count) and a wrapped row of press names set in Fraunces.<section class="faq">— four native<details>/<summary>pairs, each with a 20px plus SVG.<footer>— legal copy; bottom padding is80px + 96pxso the last row can scroll above the bar.<div class="bar" role="region" aria-label="Open an account">— fixed, outside<main>, with a meta column and one.btn-primary.
Content
- Wordmark: “Loam” with a 10px green dot. Header link: “Sign in”.
- Eyebrow: “Savings, done properly”. H1: “Money that grows while you get on with life.” (italic word in accent).
- Lede: “A savings account with a 4.35% variable rate, round-ups from every card payment and no minimum balance. Open one in four minutes.”
- Buttons: “Open an account” (primary), “See how it works” (ghost, links to
#faq). - Rate row: “4.35%” + “AER variable · interest paid monthly · deposits protected to £85,000”.
- Section h2: “Built for people who forget to save”; sub: “Swipe through what Loam does on its own.”
- Cards (tag / title / body): Automatic / “Round-ups on every card payment” / “£3.40 coffee becomes £4.00. The 60p lands in savings by midnight.” · Goals / “Pots with a finish line” / “Name a pot, set a date, and Loam works out the weekly amount for you.” · Payday / “Sweep the leftovers” / “The day before payday, anything above your buffer moves to savings.” · Protected / “Covered up to £85,000” / “Deposits held with a regulated bank partner. Withdraw any time, no notice.”
- Proof: five stars, “4.8”, “from 21,400 reviews”; press names “The Ledger”, “Weekend Money”, “Fjord Review”, “Northern Times”.
- FAQ: “Is the 4.35% rate fixed?”, “Can I withdraw whenever I want?”, “How do round-ups work with shared cards?”, “What does Loam cost?” — each with a two-sentence answer.
- Footer: “Loam Savings Ltd” + a two-line fictional regulatory notice.
- Bar: “4.35% AER variable” / “Four minutes to open. No minimum.” / “Open an account”.
Tokens
:root {
/* colour — warm cream paper, one deep green accent */
--bg: #f6f1e8; /* page */
--surface: #fffdf8; /* cards, sticky bar */
--surface-2: #ece5d8; /* proof block */
--line: #e0d7c6; /* hairlines */
--line-strong: #c9bea8; /* ghost button border, idle dots */
--ink: #1e1b16; /* headings, body */
--ink-2: #5d564b; /* lede, answers */
--ink-3: #8a8273; /* meta, footer */
--accent: #2f6b3a; /* buttons, eyebrow, icons, active dot */
--accent-2: #245430; /* button hover */
--accent-ink: #f6f1e8; /* text on accent */
--accent-soft: #dfe9d9; /* card tag background */
/* type */
--serif: "Fraunces", Georgia, serif;
--sans: "Instrument Sans", system-ui, sans-serif;
/* layout */
--safe-top: 54px; /* status bar reserve (Lounge draws it) */
--safe-bottom: 80px; /* browser URL bar reserve (Lounge draws it) */
--gutter: 20px;
--card-w: 300px;
--card-gap: 12px;
--r: 12px;
--r-lg: 20px;
--r-pill: 999px;
--shadow-bar: 0 -8px 24px rgba(30, 27, 22, .10);
/* motion */
--t-fast: 160ms;
--t-layout: 320ms;
--t-hero: 600ms;
--ease: cubic-bezier(.2, .7, .2, 1);
--ease-out: cubic-bezier(.16, 1, .3, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Body | Instrument Sans | 15px | 400 | 1.5 | 0 | sentence |
| Wordmark | Fraunces | 22px | 600 | 1 | −0.02em | sentence |
| Eyebrow | Instrument Sans | 12px | 500 | 1 | +0.12em | UPPERCASE |
| H1 | Fraunces (opsz 144) | 44px | 400 (em: 600 italic) | 1.02 | −0.025em | sentence |
| Lede | Instrument Sans | 17px | 400 | 1.5 | 0 | sentence |
| Button | Instrument Sans | 16px | 600 | 1 | 0 | sentence |
| Rate figure | Fraunces | 32px | 600 | 1 | −0.03em | numerals |
| Section h2 | Fraunces | 24px | 600 | 1.15 | −0.02em | sentence |
| Card h3 | Fraunces | 20px | 600 | 1.2 | −0.01em | sentence |
| Card tag | Instrument Sans | 11px | 500 | 1 | +0.08em | UPPERCASE |
| FAQ question | Instrument Sans | 16px | 500 | 1.35 | 0 | sentence |
| FAQ answer, card body | Instrument Sans | 14px | 400 | 1.5 | 0 | sentence |
| Bar title | Instrument Sans | 15px | 600 | 1.2 | 0 | sentence |
| Footer | Instrument Sans | 12px | 400 | 1.6 | 0 | sentence |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Notes |
|---|---|---|---|---|---|---|
.bar | hero leaves viewport | transform | translateY(calc(100% + 80px)) → 0 | 320ms | --ease-out | reverse on hero re-entering |
.top border | scroll > 0 | border-color | transparent → --line | 160ms | --ease | |
.dot::before | nearest card changes | width, background | 6px --line-strong → 18px --accent | 160ms | --ease | |
| carousel | dot tap | scrollLeft | current → card offset − 20px | native smooth | — | scroll-snap-type: x mandatory |
details p | open | opacity, translateY | 0, −4px → 1, 0 | 320ms | --ease-out | @keyframes rise |
summary svg | open | rotate, stroke | 0 → 45°, --ink-3 → --accent | 320ms | --ease-out | |
.btn-primary | hover / active | background / scale | --accent → --accent-2 / 1 → .98 | 160ms | --ease |
Reduced motion: every transition and animation duration becomes 1ms; scroll-behavior becomes auto. The bar still appears and disappears, instantly.
States
- Header stuck:
.stuckclass adds the 1px bottom hairline. Background is--bgat 86% withbackdrop-filter: blur(10px). - Primary button hover: background
--accent-2. Active:scale(.98). - Ghost button: transparent, 1px
--line-strongborder,--inktext. Hover: no change (touch-first). - Focus-visible (links, buttons, dots, summaries):
outline: 2px solid var(--accent); outline-offset: 3px. - Dot current:
aria-current="true", 18×6 pill in--accent. Others: 6px circle in--line-strong. - FAQ open:
details[open]— icon rotated 45° and green; answer visible with rise animation. Only one open at a time. - Bar hidden:
aria-hidden="true", translated fully below the viewport (including the 80px reserve) so it never peeks. - Bar shown:
.show,aria-hidden="false".
Accessibility
- Header is a
<header>; content lives in<main>; each block is a<section>witharia-labelledbypointing at its<h2>(proof block usesaria-label). - The carousel container is keyboard-scrollable (
tabindex="0",aria-roledescription="carousel",aria-label="Features"). Arrow keys scroll it natively; dots are<button role="tab">witharia-label="Slide n of 4"andaria-current. - FAQ uses native
<details>, so Enter/Space toggle and the state is exposed without ARIA. Hide the marker withsummary { list-style: none }and::-webkit-details-marker { display: none }. - The star row has
aria-label="Rated 4.8 out of 5"; the SVGs are decorative. - The sticky bar is
role="region"witharia-label; togglearia-hiddenwith visibility so the duplicate CTA is not announced while off screen. - Hit targets: buttons 52px (bar button 44px), summaries ≥ 56px, dots 24px visual inside a 24px button spaced 6px apart (acceptable as secondary controls; the carousel itself is swipeable).
- Contrast:
--ink-2on--bg7.4:1;--ink-3on--bg4.6:1 (used ≥ 12px);--accent-inkon--accent7.1:1.
Responsive rules
- 390 (reference): as specified. Cards 300px, so ~1.15 cards visible, hinting there is more.
- 360 wide: gutter stays 20px; cards shrink to
min(300px, 100vw - 60px)so the peek is preserved; H1 drops to 40px. - ≥ 600 (tablet or desktop preview): cap the content column at 560px centred, keep the bar full-width with the same 560px inner column, and set
--safe-bottom: 0because there is no bottom browser bar. Carousel cards stay 300px so the snap behaviour is still demonstrable. - Landscape phone (height < 500): the bar keeps its position; reduce its vertical padding to 8px.
Acceptance checklist
- Header is sticky at
top:0with a 54px top margin, 56px tall, and shows a--linebottom border only once the page has scrolled. - The bottom bar is fixed at
bottom: 80px, hidden bytranslateY(calc(100% + 80px)), and becomes visible only when no part of the hero is on screen. - Bar show/hide animates over 320ms
cubic-bezier(.16,1,.3,1)and togglesaria-hidden. - Carousel uses
scroll-snap-type: x mandatory; cards are 300px wide with a 12px gap andscroll-snap-align: start. - Four dots reflect the nearest card; the current dot is an 18×6px green pill.
- Tapping a dot scrolls the carousel to that card.
- FAQ is native
<details>; opening one closes the others; the plus icon rotates 45°. - Hero buttons sit side by side in a two-column grid, each 52px tall, neither wrapping its label at 390px.
- H1 is Fraunces 44px, line-height 1.02, tracking −0.025em, with the italic word in
--accentat weight 600. - Only one primary button is visible at any scroll position (hero button or bar button, never both).
- All interactive elements show a 2px
--accentoutline on:focus-visible. - No fixed element occupies the top 54px or bottom 80px.
- Reduced motion: bar and accordion still work, with ≤ 1ms motion.
Implementation notes
Drive the bar from an IntersectionObserver, not a scroll listener. Observe the hero with a rootMargin equal to the header height so “hero gone” means “gone under the header”, and check boundingClientRect.top < 0 so the bar never appears when the hero is below the viewport (e.g. on a deep-link scroll-up):
const hero = document.getElementById('hero'), bar = document.getElementById('bar');
new IntersectionObserver(([e]) => {
const past = !e.isIntersecting && e.boundingClientRect.top < 0;
bar.classList.toggle('show', past);
bar.setAttribute('aria-hidden', String(!past));
}, { threshold: [0, 1], rootMargin: '-56px 0px 0px 0px' }).observe(hero);
Hide the bar below the reserved browser-bar area, otherwise it peeks above the URL bar while hidden:
.bar { position: fixed; left: 0; right: 0; bottom: var(--safe-bottom);
transform: translateY(calc(100% + var(--safe-bottom)));
transition: transform var(--t-layout) var(--ease-out); }
.bar.show { transform: translateY(0); }
Dot sync with rAF throttling. Card pitch is width + gap (312px); round the scroll offset to the nearest pitch:
let raf = 0;
car.addEventListener('scroll', () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const i = Math.round(car.scrollLeft / 312);
dots.forEach((d, j) => d.setAttribute('aria-current', String(j === i)));
});
});
Common mistakes: forgetting padding-bottom on the footer so the last FAQ row is trapped under the bar; using display:none for the hidden bar (kills the slide); naming a global const top (collides with window.top and throws in strict environments); hiding the <details> marker only with list-style and leaving the WebKit marker.
Details
- Palette
- Type
- Fraunces · Instrument Sans
- Motion
- Subtle motion
- Build
- One session
- Tags
Studied
StripeLet one big moving thing carry the colour. Keep the words light and the cards real.
All sources