Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Promo sheet

New

A phone bottom sheet that rises over a listings screen while a gold wax seal forms in it; drag it down or tap X to dismiss.

promo-sheet/promo-sheet.tsx
Live · interactive

Services in New York City

  • Photography132 available
  • Chefs12 available
  • Massage1 available
  • Training14 available

Photography

  • PopularGolden hour portraitsFrom $45 / guest · ★ 5.0
  • Outdoor portrait sessionsFrom $45 / group · ★ 4.86
  • Natural light portraitsFrom $60 / guest · ★ 4.9

Chefs

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • One short promise to tell a phone user once, over the screen it is about: a new badge, a guarantee, a feature that just shipped.
  • A message that earns a moment of attention but should be easy to wave away: X, a drag down, the scrim or Escape.
  • When the screen behind must stay in view, so the reader knows where they are and where the promo leads.
  • A promise with one next step: a heading, a line and a single button, with a way back (the chip) once dismissed.

When not to use

  • A choice the reader must make before going on (delete, pay, sign out). Use an alert dialog with two plain buttons.
  • A list of options or a form. Use an action sheet or a full-screen modal with its own navigation.
  • Something to say on every visit. Show it once and keep it behind the chip, or use an inline banner in the list.
  • An error or a status that needs no decision. Use a toast or an inline message.
  • A desktop layout. It is drawn as a 390-point phone; on a wide page use a centred dialog or a card.

You can add this component with the shadcn CLI or by hand:

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

vetted-promo.tsx
import { PromoSheet } from './promo-sheet';export function VettedPromo() {  return (    <PromoSheet      title="Services on Stayline are vetted for quality"      actionLabel="Explore services"      onAction={() => console.log('explore')}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A short promo for a listings app: the sheet rises over the search screen, which stays visible behind a scrim and recedes into a card. A lemon drop swells into a gold wax seal with a check pressed into it. Drag the sheet down, tap the X or the scrim, or press Escape to dismiss it; the Vetted chip brings it back.

  • The phone is drawn in CSS at its design size, a 390 × 844 screen in a 412 × 864 body, and scaled as one piece to fit the stage. The scale comes from the stage’s own size (a size container; tan(atan2()) of two lengths is their ratio), so nothing inside it lays out again.
  • Opening is three CSS transitions on one 500 ms quint ease-out: the sheet slides up from below, the scrim fades in to 45% black, and the page behind moves 58 px down and shrinks to 95.5% width, so it reads as a card. The status bar turns white on black. Closing runs the same transitions back in 340 ms.
  • A transparent raster 3D render supplies the gold wax texture, raised rim and embossed check. A CSS sphere swells from a speck between 240 and 460 ms, then the rendered seal spreads into place with a slight turn and overshoot until about 950 ms. Service tiles and featured sessions use photographic images.
  • Dragging the sheet sets its transform directly, frame by frame, with a rubber band upwards. The scrim and the receding page follow its progress. On release, the styles go back to the CSS transitions: past a third of the sheet’s height, or flicked faster than 0.5 px/ms, it closes on a faster 260 ms curve; otherwise it springs back.
  • A drag that starts on a button is not a press of it: the click is swallowed once the finger has moved 6 px.
  • While on screen and untouched, a demo finger taps the X, reopens the sheet with the chip, drags it away, and presses its button, all in a loop. Any input from the reader hands control to them.
  • Each layer’s resting style is its final state, so under reduced motion the sheet and the finished seal simply appear.
PropTypeDefaultDescription
titlestring'Services on Stayline are vetted for quality'The sheet’s heading, and its accessible name.
descriptionstring'Services are evaluated for expertise and reputation.'The line under the heading.
actionLabelstring'Explore services'The primary button’s words.
onAction() => void—Called when the reader presses the primary button; the sheet closes as well.
defaultOpenbooleantrueThe sheet rises on its own the first time the phone comes on screen.
defaultState'open' | 'closed' | 'dragging'—Opens already in this state, with no rise and no demo: the sheet up with its seal formed, the listings with the sheet away, or the sheet held 30% of the way down as if dragged.
sealAtnumber—Holds the first seal still at this many milliseconds of its 1.5 s forming (the sheet opens up, with no demo).
autoplaybooleantrueWhile visible and untouched, a demo finger closes the sheet, drags it away and reopens it. Any input from the reader stops it.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Added to the stage element.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

promo-sheet.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type PointerEvent as ReactPointerEvent,} from 'react';import { useAutoplay } from './use-autoplay';import styles from './promo-sheet.module.css';/* * Promo sheet: a phone showing a listings screen, over which a bottom sheet rises to tell the * reader that the services listed are vetted. A gold wax seal forms in the sheet as it lands; an * X and a drag down dismiss it, and the "Vetted" chip by the section title brings it back. * * - The phone is drawn in CSS at its design size (a 390 × 844 screen in a 412 × 864 body) and
promo-sheet.module.css
/* Promo sheet (promo-sheet.tsx): a phone on a quiet stage. Everything inside the phone is drawn at   its design size (a 390 × 844 screen in a 412 × 864 body) and the phone is scaled as one piece   to fit the stage, by a ratio CSS works out from the stage's size (a size container): tan(atan2)   of two lengths is their ratio as a plain number.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme).   Memory. The stage is one layout boundary sized from outside; the sheet, the scrim, the page   behind and the seal's layers move by transforms and opacities only. */.root {  /* The stage. */  --stage: #f4f4f3;  --stage-line: rgb(0 0 0 / 0.05);  /* The listings page: its header fades from grey to white. */  --page: #fff;  --header-top: #e3e3e3;  --header-bottom: #fcfcfc;  --header-shadow: rgb(0 0 0 / 0.08);  --ink: #222;  --muted: #6a6a6a;  --pill: #fff;
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(
  • The sheet is a modal dialog (role="dialog", aria-modal) named by its heading and described by its text. While it is open the page behind is inert; while it is closed the sheet is.
  • Opening it from the chip moves focus into the sheet. Escape, the X or a tap on the scrim closes it, and focus goes back to the chip, which has aria-haspopup="dialog" and aria-expanded.
  • Every control is a real button with a visible :focus-visible ring. The category tabs and the save hearts use aria-pressed.
  • A live region announces a dismissal by drag and a press of the primary button.
  • The phone frame, status bar, service photographs, seal and demo finger are decorative and hidden from assistive technology.
  • Under reduced motion the sheet appears and leaves without sliding, the seal appears finished, and the demo finger never plays.