Skip to content
LandingPagePrompts
Landing Pagestealth/space-bunny-alphaverified 17/17partial cross-check

Smolder

An 18-word hero is revealed by scroll progress, with each word cooling from an ember glow into bone-white locked text.

The exact prompt

A. WHAT IS BEING BUILT

Build a single-file, scroll-mapped cinematic landing page for Longhand, a slow-reading essay platform. The output is exactly one index.html containing semantic HTML, plain CSS, and plain JavaScript. Set document.documentElement.lang to "en", set the document title to "Longhand — One long thought, every week", and set the viewport meta tag to width=device-width, initial-scale=1. The visual system is a warm-black editorial page with one 18-word opening paragraph as the hero. This is explicitly a scroll piece: the page must be taller than the viewport and use document scrolling to scrub the paragraph. At desktop widths of 901px and above, do not force overflow:hidden because the scroll mapping is the primary interaction. The body must have overflow-x:hidden, and the page must never produce horizontal overflow. At widths below 901px, retain normal vertical scrolling. The hero is left aligned to a 62ch measure, positioned in the lower two thirds of the first viewport, while the entire right half remains empty warm black. The hero paragraph uses clamp(28px, 4.6vw, 68px) and line-height 1.08. There is no navigation above the paragraph. A single 11px uppercase navigation line is pinned to the bottom of the first viewport.

Do NOT build a feed, article grid, like button, share control, account menu, carousel, modal, image, video, texture, paper grain, vellum surface, letterpress effect, decorative illustration, logo mark, gradient orb, star field, loading screen, cookie banner, or secondary content section. Do NOT add a hero image or any external media. Do NOT add a second paragraph, rotating testimonials, counters, pricing cards, or a footer CTA. Do NOT use React, Tailwind, GSAP, Three.js, Lottie, WebGL, canvas, SVG filters, remote fonts other than Google Fonts, mp4, webm, gif, remote images, CDN URLs, base64 assets, or third-party scripts. Do NOT use a timer to drive the permanent word reveal. Do not add hidden interactive controls. Every visible control must have an accessible name.

B. THE BACKDROP

Use one body background built from CSS gradients. The base color is #14100E. Add exactly two radial gradients with no external image: radial-gradient(circle at 12% 78%, rgba(255,140,40,0.055) 0%, rgba(255,140,40,0.022) 24%, transparent 54%), and radial-gradient(circle at 4% 92%, rgba(245,239,230,0.025) 0%, transparent 35%). Add one linear overlay: linear-gradient(180deg, rgba(255,255,255,0.012) 0%, transparent 28%, rgba(0,0,0,0.12) 100%). The gradient stops and positions are fixed. Do not animate background-position, blur, opacity, or gradient values.

There are 18 semantic word groups. Each group contains one base word span and one ember duplicate span. The duplicate is positioned absolutely inside a relatively positioned word group, has the same font metrics and text, and is colored #FF8C28 with text-shadow: 0 0 26px rgba(255,140,40,0.35). The base span is colored #F5EFE6. The base word is at opacity 1 when locked, while its color is visually softened by the surrounding black; the initial perceived unread state is #6B6259 through a controlled opacity ramp beginning at 0.14. The ember duplicate is visible only during the ignition phase and fades to opacity 0 after the word locks.

The document must have a scroll track height of 180vh for desktop and 220vh for mobile. The first viewport contains the fixed-position hero stage. The word positions are obtained from one getBoundingClientRect() read per animation frame on the paragraph container. The paragraph's document position is cached at startup and on resize, but the live container rect is read exactly once per rAF callback before updating the 18 words. The scroll progress is calculated from window.scrollY and the cached paragraph travel range. Each word uses a 34%-viewport travel window. A word's index offset is 18px of scroll later for every next word, implemented as progressOffset = wordIndex * 18 / viewportHeight. A word's reveal progress is clamp((progress - progressOffset) / 0.34, 0, 1). The base opacity is 0.14 + 0.86 * revealProgress. The ember opacity is a triangular ignition envelope: ramp from 0 to 1 over the first 12% of the word's 34% travel, then ramp from 1 to 0 over the next 12%, with the remaining 10% at 0. The ember duplicate therefore never animates text-shadow itself; only opacity is animated, preserving the performance constraint. Apply opacity to the 18 base spans and 18 ember spans per frame. Do not write any transform in the scrub loop.

There is no canvas. The canvas count is exactly 0, the canvas resolution is not allocated, and the canvas resolution trick is intentionally not used. The CSS background is resolution independent. The per-frame budget is 18 base opacity writes plus 18 ember opacity writes, one cached scrollY read, one paragraph rect read, and no layout reads for individual words. Use only opacity updates inside the rAF loop. Do not call getBoundingClientRect on any item inside the loop. The target is at least 20 frames per 1.2 seconds on a 1440x900 headless viewport; the 36 opacity writes are inexpensive and the loop must continue while scrolling. When scroll has not changed, the loop may still increment the telemetry frame counter but must not rewrite identical opacity values.

C. THE LAYER STACK

Build the layers back to front with these exact z-index values:

z-index: -3 for the body gradient background.
z-index: -2 for the optional non-interactive black vignette overlay, implemented as a body pseudo-element and containing no pointer events.
z-index: 0 for the document scroll track.
z-index: 10 for the fixed hero stage.
z-index: 20 for the eyebrow, hero title, and paragraph inside the stage.
z-index: 30 for the bottom navigation line.
z-index: 40 for the focus ring layer created by :focus-visible on the only visible navigation control.
z-index: 50 for no content; the telemetry object is not rendered.

The fixed hero stage has pointer-events:none except for the bottom navigation control. The paragraph remains selectable text and must not have pointer-events:none if that prevents text selection; set pointer-events:auto on the paragraph while keeping the decorative duplicate spans pointer-events:none. The body gradient must be fixed and the scroll track must provide actual document height.

D. TOKEN TABLE

Define these CSS custom properties in :root and use them everywhere:

--bg: #14100E;
--bone: #F5EFE6;
--ember: #FF8C28;
--ash: #6B6259;
--white: #F5EFE6;
--muted: rgba(245,239,230,0.58);
--faint: rgba(245,239,230,0.34);
--line: rgba(245,239,230,0.14);
--ember-soft: rgba(255,140,40,0.16);
--stage-height: 100dvh;
--track-height: 180vh;
--measure: 62ch;
--hero-top: 31vh;
--hero-left: clamp(28px, 8vw, 144px);
--hero-bottom: 15vh;
--hero-gap: 24px;
--eyebrow-size: 11px;
--eyebrow-tracking: 0.18em;
--title-size: clamp(28px, 4.6vw, 68px);
--title-leading: 1.08;
--title-weight: 450;
--title-tracking: -0.035em;
--nav-size: 11px;
--nav-tracking: 0.16em;
--nav-padding-inline: clamp(28px, 8vw, 144px);
--nav-padding-block: 18px;
--nav-gap: 28px;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-standard: cubic-bezier(0.22, 1, 0.36, 1);
--duration-load: 600ms;
--duration-hover: 180ms;
--stagger: 120ms;
--word-window: 0.34;
--word-index-offset-px: 18px;
--glow-r: 0;
--glow-g: 140;
--glow-b: 40;
--glow-radius: 26px;
--glow-alpha: 0.35;
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ember);
--radius-pill: 999px;
--radius-control: 2px;
--max-width: 1440px;
--header-height: 0px;
--footer-height: 47px;

The font stack is "DM Sans", "Helvetica Neue", Arial, sans-serif for UI, and "Cormorant Garamond", Georgia, serif for the paragraph. Load exactly one permitted Google Fonts stylesheet for DM Sans and Cormorant Garamond with weights 400, 500, and 600 for DM Sans and 400, 500 for Cormorant Garamond. If the network request fails, use the listed system fallbacks without blocking first paint.

E. THE COMPOSITION

Use real header, main, and footer elements. The document structure is header#site-header, main#main-content containing section#hero, then footer#site-footer containing nav#bottom-nav. The header has height 0, padding 0, and contains no visible content. It may contain a visually hidden h1? Do not do that: the hero paragraph is the one h1. The h1 is the complete 18-word paragraph, with the exact text: "A quiet page can hold one thought without asking you to hurry through; read slowly, then carry it." The h1 must be the only h1 on the page.

The main element has min-height:var(--track-height), position:relative, and padding-top:var(--stage-height). The fixed hero stage is position:fixed, inset:0, height:var(--stage-height), width:100vw, and contains a content block with position:absolute, left:var(--hero-left), right:8vw, top:var(--hero-top), bottom:var(--hero-bottom), display:grid, grid-template-rows:auto 1fr, row-gap:var(--hero-gap), align-content:end, max-width:var(--measure). Because the right 8vw remains empty, the content width is capped at 62ch and does not stretch across the viewport. Use a single column; no cards or side illustrations.

The eyebrow is a p element with class eyebrow. Its exact copy is "LONGHAND / ISSUE 014". It is uppercase, font-size:var(--eyebrow-size), line-height:1, letter-spacing:var(--eyebrow-tracking), color:var(--faint), margin:0, and opacity 0 initially. The h1 is the paragraph itself, not a separate short title. Its width is 62ch, max-width:100%, margin:0, color:var(--bone), font-family:"Cormorant Garamond", Georgia, serif, font-size:var(--title-size), line-height:var(--title-leading), font-weight:var(--title-weight), letter-spacing:var(--title-tracking), text-wrap:balance, overflow-wrap:break-word. Each word is a span.word with position:relative, display:inline-block, white-space:nowrap. The base span has position:relative, z-index:1, and the ember span has position:absolute, inset:0, z-index:0, pointer-events:none, color:var(--ember), text-shadow:0 0 var(--glow-radius) rgba(var(--glow-r),var(--glow-g),var(--glow-b),var(--glow-alpha)); opacity 0.

The footer is position:fixed, left:0, right:0, bottom:0, height:var(--footer-height), z-index:30, border-top:1px solid var(--line), background:rgba(20,16,14,0.86), backdrop-filter:blur(8px). The navigation is display:flex, align-items:center, justify-content:space-between, height:100%, padding:0 var(--nav-padding-inline), gap:var(--nav-gap). It contains exactly one visible control, an a element with href="#current-issue", accessible name "Read the current issue", and exact copy "READ THE CURRENT ISSUE ↗". It also contains a non-interactive span with exact copy "ONE LONG PIECE / WEEK". The navigation line is 11px uppercase with letter-spacing var(--nav-tracking). The link uses color:var(--bone), text-decoration:none, border-bottom:1px solid transparent, padding:7px 0. The helper span uses color:var(--faint). The anchor is keyboard focusable and has a visible focus ring.

F. THE UI LANGUAGE

The only button-like control is the bottom navigation link. It has no filled background. Its default state is transparent with bone text and a 1px transparent bottom border. On hover, change color to var(--ember), border-bottom-color:var(--ember), and transform:translateY(-1px), with transition:color var(--duration-hover) var(--ease-standard), border-color var(--duration-hover) var(--ease-standard), transform var(--duration-hover) var(--ease-standard). On focus-visible, apply the exact focus ring box-shadow var(--focus-ring), outline:none, and retain the link's position. Do not add a pill button. The nav helper is a text label, not a control. All control hit areas are at least 44px high through nav padding and line-height.

G. MOTION

The permanent scrub is frame-rate bound to scroll. It starts from the page's current scroll position and updates the 18 base and 18 ember opacities from the paragraph's live rect and cached travel distance. It must not use setInterval, a synthetic scroll loop, or an autonomous timer. The initial page state is scrollY 0, with all base words at opacity 0.14 and all ember words at opacity 0.

The load animation is once-only and applies to the eyebrow and the h1 as two elements. The eyebrow animates from opacity 0, transform:translateY(18px), to opacity 1, transform:translateY(0), over 600ms with ease-out. The h1 follows after a 120ms delay using the same 600ms transform and opacity animation. Do not animate individual word shadows during load. The scrub may begin immediately and may override the h1's opacity only for the word spans, not the h1 container.

Stagger table:

eyebrow -> rise-and-fade -> delay 0ms
h1 -> rise-and-fade -> delay 120ms
base word 0 through word 17 -> scroll scrub -> no timer delay; index offset 0px through 306px
ember word 0 through word 17 -> scroll scrub ignition envelope -> same index offset as its base word
bottom navigation -> fade-in -> delay 240ms, 360ms duration

Named keyframes:

@keyframes riseAndFade { from { opacity:0; transform:translateY(18px); } to { opacity:1; transform:translateY(0); } }
@keyframes navFade { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }

Use animation-fill-mode:both. Remove the load animation from the h1 after 720ms only if doing so does not affect the scrubbed child opacities. The footer must remain readable if animation is interrupted.

H. THE RESPONSIVE TABLE

At 390x844, set --track-height:220vh; --hero-left:24px; --hero-top:24vh; --hero-bottom:96px; --hero-gap:16px; --measure:100%; --title-size:clamp(32px, 10vw, 48px); --title-leading:1.12; --eyebrow-size:10px; --nav-padding-inline:24px; --nav-padding-block:16px; --nav-gap:16px; --footer-height:64px. The navigation wraps to two rows only if required by width, using flex-wrap:wrap and row-gap:6px, with the link first and the helper second. Keep all text inside the viewport.

At 768x1024, set --track-height:200vh; --hero-left:56px; --hero-top:28vh; --hero-bottom:92px; --hero-gap:20px; --measure:62ch; --title-size:clamp(40px, 7vw, 60px); --nav-padding-inline:56px; --footer-height:56px. Keep the fixed stage height 100dvh.

At 1440x900, set --track-height:180vh; --hero-left:clamp(72px, 8vw, 144px); --hero-top:31vh; --hero-bottom:15vh; --measure:62ch; --title-size:clamp(28px, 4.6vw, 68px); --footer-height:47px. The hero must occupy the left 62ch and leave the right half empty.

At 1920x1080, set --hero-left:clamp(96px, 8vw, 176px); --hero-top:33vh; --hero-bottom:16vh; --measure:62ch; --title-size:68px; --track-height:180vh; --footer-height:47px. Do not scale the paragraph above 68px.

Add a max-height:760px breakpoint for short desktop windows: set --hero-top:18vh; --hero-bottom:80px; --title-size:clamp(28px, 4.2vw, 54px); --title-leading:1.1; --footer-height:47px. Add a max-height:600px breakpoint: set --hero-top:12vh; --hero-bottom:72px; --hero-gap:10px; --eyebrow-size:10px; --title-size:clamp(26px, 4vw, 44px); --title-leading:1.06; --footer-height:43px; --nav-padding-block:12px. The short-height overrides must preserve the full paragraph and bottom navigation.

I. FAILURE GUARDS

All visible resting elements must have opacity 1 after their one-time entrance animation, except the scroll-controlled word base spans and ember duplicates. The h1, eyebrow, footer, and navigation must never remain hidden if JavaScript fails. Put the default eyebrow opacity at 1 in CSS and apply the entrance animation through a class added by JavaScript, so pre-JS content is readable. The base word spans must default to opacity 0.14 and the ember duplicates to opacity 0. The page must be usable before JavaScript executes.

Use a two-rAF fallback. After first paint, requestAnimationFrame(function(){ requestAnimationFrame(function(){ telemetry.ready = true; }); });. In the main animation loop, after the first successful frame, set ready true if it is still false. If requestAnimationFrame is unavailable, run one opacity initialization call and set ready true without an exception. Wrap all JavaScript setup in try/catch, push caught messages into telemetry.errors, and do not log to the console.

Implement prefers-reduced-motion with @media (prefers-reduced-motion: reduce). Set animation:none, transition:none, scroll-behavior:auto. Set the eyebrow, h1, and footer to opacity 1 and transform:none. Set every base word to opacity 1 and every ember duplicate to opacity 0. Preserve the scroll-driven opacity mapping for base words, because it communicates reading position, but remove the load and hover transitions. The reduced-motion page must remain static and readable, with all 18 words at opacity 1 and no glow.

J. THE EXACT JS

Place this exact script at the end of body:

<script>
(() => {
  "use strict";
  const telemetry = { name: "smolder", ready: false, frames: 0, effects: [], errors: [] };
  window.__DEMO__ = telemetry;
  window.addEventListener("error", event => {
    telemetry.errors.push(String(event.message || "window error"));
  });

  const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
  const hero = document.getElementById("hero");
  const paragraph = document.getElementById("opening-paragraph");
  const footer = document.querySelector("#site-footer");
  const wordNodes = paragraph ? Array.from(paragraph.querySelectorAll(".word")) : [];
  const baseNodes = wordNodes.map(node => node.querySelector(".base-word"));
  const emberNodes = wordNodes.map(node => node.querySelector(".ember-word"));
  let cachedTop = 0;
  let cachedTravel = 1;
  let lastScrollY = -1;
  let lastProgress = -1;
  let rafId = 0;

  telemetry.effects.push("load-rise-fade", "scroll-word-reveal", "ember-ignition", "ember-cooling");

  function measure() {
    if (!paragraph) return;
    const rect = paragraph.getBoundingClientRect();
    const viewportHeight = Math.max(1, window.innerHeight);
    cachedTop = rect.top + window.scrollY;
    cachedTravel = Math.max(1, paragraph.offsetHeight + viewportHeight * 0.34);
  }

  function clamp(value, min, max) {
    return Math.max(min, Math.min(max, value));
  }

  function updateWords() {
    if (!paragraph || !wordNodes.length) return;
    const rect = paragraph.getBoundingClientRect();
    const viewportHeight = Math.max(1, window.innerHeight);
    const progress = clamp((viewportHeight * 0.34 - rect.top) / cachedTravel, 0, 1);
    if (Math.abs(progress - lastProgress) < 0.0001 && lastScrollY === window.scrollY) return;
    lastProgress = progress;
    lastScrollY = window.scrollY;

    for (let index = 0; index < wordNodes.length; index += 1) {
      const offset = index * 18 / viewportHeight;
      const local = clamp((progress - offset) / 0.34, 0, 1);
      const ignitionIn = clamp(local / 0.3529, 0, 1);
      const cooling = clamp((local - 0.3529) / 0.3529, 0, 1);
      const ember = ignitionIn >= 1 ? 1 - cooling : ignitionIn;
      baseNodes[index].style.opacity = String(0.14 + local * 0.86);
      emberNodes[index].style.opacity = reduce.matches ? "0" : String(ember);
    }
  }

  function frame() {
    telemetry.frames += 1;
    updateWords();
    if (!telemetry.ready) telemetry.ready = true;
    rafId = window.requestAnimationFrame(frame);
  }

  function start() {
    try {
      measure();
      updateWords();
      if (footer) footer.classList.add("is-ready");
      requestAnimationFrame(() => requestAnimationFrame(() => {
        telemetry.ready = true;
      }));
      rafId = window.requestAnimationFrame(frame);
    } catch (error) {
      telemetry.errors.push(String(error && error.message ? error.message : error));
      telemetry.ready = true;
    }
  }

  window.addEventListener("resize", measure, { passive: true });
  window.addEventListener("scroll", measure, { passive: true });
  start();
})();
</script>

---

TECHNICAL CONTRACT (non-negotiable):

1. Deliver ONE single file: a complete, valid `index.html`. No build step, no bundler,
   no framework, no package install. It must open correctly by double-clicking the file
   and also over http://.
2. Vanilla only. No React, no Tailwind CDN, no GSAP, no Three.js, no jQuery, no Lottie.
   Plain HTML + CSS + modern JavaScript in <style> and <script> tags.
3. NO external media. No .mp4, no .webm, no .gif, no remote images, no base64 blobs of
   photos. Every visual must be generated procedurally with CSS gradients, SVG, or
   <canvas>. This keeps the page free to host and impossible to break with a dead link.
4. Google Fonts is the ONLY permitted network request, and it must be declared with a
   <link rel="preconnect"> plus a system-font fallback stack so the page still looks
   intentional offline. If you prefer, skip it and use a pure system stack.
5. Responsive and correct at 390x844 (phone), 768x1024 (tablet) and 1440x900 (desktop).
   No horizontal scrollbar at any of those widths. Nothing may overflow its container.
6. Accessibility: exactly one <h1>; real <header>/<main>/<footer>/<nav> landmarks; every
   interactive element is a <button> or <a> with an accessible name; visible :focus-visible
   ring; body text contrast ratio >= 4.5:1 against its background.
7. Motion safety: wrap all continuous motion in
   `@media (prefers-reduced-motion: reduce)` rules that reduce it to a static or
   near-static state. The page must remain fully readable and usable with motion off.
8. Performance: no layout thrash. Cache DOM reads, batch writes inside a single
   requestAnimationFrame loop, use transform/opacity for animation, and never allocate
   inside the hot loop. Target 60fps on a 2019 laptop. Total file under 90 KB.
9. Runtime telemetry hook (used by the automated verifier — implement it exactly):
   at the top of your script define
     window.__DEMO__ = { name: '<slug>', ready: false, frames: 0, effects: [], errors: [] };
   then set `ready = true` after the first paint of your main effect, increment
   `frames` once per animation frame inside your rAF loop, and push the name of each
   implemented effect into `effects`. Add a `window.addEventListener('error', ...)`
   handler that pushes messages into `errors`. Do not rename these fields.
10. Zero console output in the happy path. No console.log left behind. No uncaught
    exceptions, no failed network requests.

RESPONSE FORMAT:
Return ONLY the raw HTML inside a single ```html fenced code block. No explanation, no
preamble, no commentary before or after the fence. The file must be complete and
immediately runnable — never truncate, never write "// rest of code here".

Acceptance criteria (15)

  1. The delivered artifact is one valid index.html with no external media and contains exactly one h1 containing the exact 18-word Longhand paragraph.
  2. At 1440x900, the document has vertical scroll height of 180vh, the hero measure is capped at 62ch, and the right half contains no visible content or controls.
  3. Scrolling changes the 18 base word opacities from 0.14 toward 1, with each word offset by 18px relative to the previous word and ember duplicates returning to opacity 0 after cooling.
  4. The page exposes window.__DEMO__ with name smolder, ready true after first paint, an incrementing frames value, four named effects, and no uncaught window errors during load and scroll.
  5. With prefers-reduced-motion enabled, all entrance and hover transitions are disabled, all 18 words are readable, and the bottom navigation link retains an accessible name and visible focus-visible ring.
  6. window.__DEMO__.ready becomes true within 5s
  7. window.__DEMO__.frames advances (>= 20 frames in 1.2s)
  8. window.__DEMO__.errors is empty
  9. zero console errors and zero page errors
  10. zero failed network requests
  11. exactly one <h1> with non-empty text
  12. header, main, footer landmarks present
  13. no horizontal overflow at 390 / 768 / 1440 widths
  14. no horizontal overflow after 2s of animation
  15. prefers-reduced-motion: reduce leaves the page usable and errors empty

More landing page prompts