# Health sheet

> A phone sheet that sums up a health finding, with the organ glowing inside a translucent figure and cards that come in one after another.

A warm bottom sheet on a phone. A translucent figure shows the area at stake with its organs glowing orange. Below it are two cards: how likely the issue is to matter, with a chip and four signal bars, and how soon, with a clock. Tabs or a sideways swipe move between lungs, heart and mouth: the light moves to the new organs, the figure pans down to the face for the mouth, the cards come in again and the bars fill up to the new risk.

A React component by Bruno Delorence: https://brunodelorence.com/components/health-sheet. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Sheet**: Rises in on a spring the first time it is on screen; closing lowers it onto the screen it came from.
2. **Close**: Lowers the sheet and moves the focus to the area’s row on the screen behind.
3. **Area tabs**: A segmented control, one tab per area. Arrows, Home and End move between them; switching is announced.
4. **Figure**: A translucent body drawn once; it pans down to the face for the mouth. A sideways swipe on it switches areas.
5. **Lit organs**: An orange copy of the area’s organs on a box of its own, faded in and breathing slowly while on screen.
6. **Title**: The issue’s name under the area’s, centred over the fading figure.
7. **Risk card**: How likely it is to matter: a chip in words and four bars that fill up to the risk, then a few sentences.
8. **Time card**: How soon it may matter, with a clock, and what that time means. The note under it says it is not a diagnosis.

## When to use

- Summing up one finding about one area of the body on a phone: what it is, how likely it is to matter, how soon.
- Two to four areas a person moves between with tabs or a sideways swipe, each with the same two cards.
- A screen opened from a list (a health map, a report), where closing the sheet returns to it.
- Risk that reads at a glance: a chip in words and four bars, never a colour alone.

## When not to use

- Advice, a diagnosis or anything that asks for a decision. Send people to a professional; the sheet only informs.
- Many findings at once, or results to compare over time. Use a list or a chart.
- Desktop layouts with room to spare: show the figure and the cards side by side instead of in a sheet.
- Areas without a drawing of their own. The lit organs are part of the figure, not generated from data.
- Live readings (heart rate, a sensor). The sheet tells a settled summary, not a value that changes.

## Installation

Install it with the shadcn CLI, into components/health-sheet of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/health-sheet.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
import { HealthSheet } from './health-sheet';
import { AREAS } from './areas';

export function FindingSheet({ onDismiss }: { onDismiss: () => void }) {
  return <HealthSheet autoplay={false} areas={AREAS} defaultArea={0} onClose={onDismiss} />;
}
```

## States

- **Lungs · medium risk**: The first area: three of four bars lit, the chest’s airways glowing.
- **Heart · low risk**: One bar lit and an amber chip; the light moves to the heart.
- **Mouth · high risk**: The figure pans down to the face; all four bars lit.
- **Closed**: The sheet lowered onto the health map behind; a row opens it again on its area.
- **Dark**: Set by `colorScheme="dark"` or the page’s scheme: a graphite phone and a warm dark sheet. The organs glow the same orange.

## How it works

- The phone is drawn in points, and one CSS variable converts a point to pixels, so the whole phone scales as one picture to fit its stage.
- The body is one SVG with its organs drawn a shade off the skin, running on behind the area’s name and the title and fading out under them. Each organ group has a lit copy (a gradient, a blurred halo and a soft spill of light) on a box sized just to it, and switching areas fades one copy out and the next in. For the mouth the figure pans down to the face, the only area that gives it a transform, and the lit organ breathes with a slow CSS animation that pauses off screen.
- The first time the phone comes into view, the sheet rises on a spring. On each area change, the cards and the closing note come in one after another, and the risk bars scale up from the bottom to the risk level.
- The list scrolls by hand: an overflow-hidden box on a layer of its own, moved by the wheel, by touch with coasting, and by the arrow keys, Page keys, Space, Home and End, with a thin scroll indicator. A native scroll area would cost a phone’s browser extra layers at full resolution, and at the list’s ends the gesture passes to the page.
- The close button lowers the sheet onto a “Health map” screen behind it, from which any area reopens the sheet. The demo finger taps Heart, scrolls the list down and back, swipes to Mouth, then taps Lungs.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. By day a silver phone sits on a light stage and its sheet is a warm off-white fading to pale peach; at night a graphite phone sits on a near-black stage and the sheet is dark and warm. The figure takes its fills from the tokens by class, and the lit organs glow orange in both.

## API reference

- **areas** (`Area[]`, default `AREAS`): The areas in tab order (lungs, heart, mouth), each with its finding: name, risk, time to risk and copy.
- **defaultArea** (`number`, default `0`): The area shown at first, as its index in areas.
- **defaultOpen** (`boolean`, default `true`): Whether the sheet starts up; false opens on the health map behind, the sheet lowered. Given at all, it turns the demo off.
- **rise** (`boolean`, default `true`): The sheet rises in the first time the phone comes into view, and the cards come in; false puts it in place from the start.
- **onClose** (`() => void`): Called when the sheet is closed with its close button.
- **onAreaChange** (`(area: Area, index: number) => void`): Called when the reader switches areas, by tab, key or swipe, with the area and its index in areas.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo finger switches areas, scrolls and swipes.
- **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.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- The areas are a tablist with one tab stop: the arrow keys, Home and End move between the tabs and select them. The list is the tab panel, labelled by its tab, and a reader’s change is announced, for example “Lungs & Airways: Airway Inflammation. Medium Risk, within less than 3 months.”
- The list is focusable and scrolls with the keyboard. The signal bars are one image labelled “3 of 4 bars”, and the figure, the phone frame and the status bar are hidden from screen readers.
- The close button is labelled. Closing moves focus to the screen behind and reopening moves it back to the tab; whichever screen is hidden is inert. Focus rings are visible.
- Under reduced motion the sheet does not rise, the cards do not stagger, nothing breathes and there is no demo. The copy is general and cautious, and it ends with the line “This summary is general information, not a diagnosis.”

## Source

### health-sheet/health-sheet.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type ReactElement,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent,
} from 'react';
import { AREAS, RISK_BARS, RISK_LABEL, type Area } from './areas';
import { Body, BODY_BOX, LIT_BOX, LitHeart, LitLungs, LitMouth, VIEW } from './body';
import { DemoTouch, runDemo } from './demo-touch';
import { ClockIcon, CloseIcon, StatusIcons } from './icons';
import { useAutoplay } from './use-autoplay';
import { usePanelScroll } from './use-panel-scroll';
import styles from './health-sheet.module.css';

/*
 * Health sheet: a phone's bottom sheet that sums up one finding about an area of the body.
 *
 * - The sheet shows a translucent figure with the area's organs lit orange, the issue's
 *   name, and two cards: how likely it is to matter (a chip and four bars) and how soon (with a
 *   clock). Its list scrolls by hand (use-panel-scroll.ts: wheel, touch, keys), and its close
 *   button lowers it onto the screen behind.
 * - Three areas, Lungs, Heart and Mouth, switch with the tabs at the top or a sideways swipe on
 *   the figure. The figure pans (down to the face for the mouth), the light moves to the
 *   area's organs, and the copy changes.
 * - The motion is in the same language throughout: the sheet rises in on a spring the first time
 *   it comes into view, the lit organs breathe slowly, the cards come in one after the other and
 *   the bars fill up to the risk.
 * - Everything moves with transforms and opacity. The figure is drawn once, on a layer cut to the
 *   body that the pan only moves; each organ group has a lit copy, with its glow, on a box of its
 *   own just big enough for it, faded in and out, so no drawing is ever restyled. The list is
 *   the only layer the size of the sheet: it paints the sheet's background too.
 *
 * HealthSheet draws the phone on a stage of its own; while it is on screen and untouched, a demo
 * finger switches areas, scrolls the sheet and swipes the figure.
 *
 * Light or dark: the stage, the phone's frame and the screen's interface are light-dark() tokens in
 * the CSS, so the scheme is the page's color-scheme, or the one the colorScheme prop sets: by day a
 * silver phone on a light stage, its sheet light with a pale figure; at night a graphite phone on
 * a near-black stage, its sheet warm and dark with a dark figure. The lit organs glow the same
 * orange in both, and the bezel and the black round the screen behind stay black, as a phone's.
 */

/** How long a swipe must travel to switch areas, in px. */
const SWIPE = 36;

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const spring = 'cubic-bezier(0.2, 0.9, 0.25, 1.05)';

/** The custom properties that place a box of the figure (x, y, width and height, in points). */
const boxVars = ([x, y, w, h]: readonly [number, number, number, number]) =>
  ({ '--x': x, '--y': y - VIEW.top, '--w': w, '--h': h }) as CSSProperties;

/** Each area's lit drawing. */
const LIT: Record<Area['id'], () => ReactElement> = {
  lungs: LitLungs,
  heart: LitHeart,
  mouth: LitMouth,
};

export function HealthSheet({
  areas = AREAS,
  defaultArea = 0,
  defaultOpen,
  rise = true,
  onClose,
  onAreaChange,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The areas, in the order of their tabs: lungs, heart and mouth, each with its finding. */
  areas?: Area[];
  /** The area shown at first, as its place in `areas`. */
  defaultArea?: number;
  /**
   * false opens on the screen behind, the sheet lowered; given at all, it turns the demo off.
   * Defaults to open.
   */
  defaultOpen?: boolean;
  /** false: the sheet is in place from the start, with no rise and no cards coming in. */
  rise?: boolean;
  /** Called when the sheet is closed with its close button. */
  onClose?: () => void;
  /** Called when the reader switches areas (tabs, keys, a swipe), with the area and its place. */
  onAreaChange?: (area: Area, index: number) => void;
  /** While on screen and untouched, a demo finger switches areas, scrolls and swipes. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Extra class for the root, a stage of its own. */
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(7000);
  const [index, setIndex] = useState(() => Math.min(areas.length - 1, Math.max(0, defaultArea)));
  const [open, setOpen] = useState(defaultOpen ?? true);
  const [announcement, setAnnouncement] = useState('');
  /** Keyboard focus is on the stage: the demo leaves it alone. */
  const [keyboard, setKeyboard] = useState(false);
  const sheet = useRef<HTMLElement>(null);
  const scroller = useRef<HTMLDivElement>(null);
  const thumb = useRef<HTMLSpanElement>(null);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const hero = useRef<HTMLDivElement>(null);
  const reopen = useRef<HTMLButtonElement>(null);
  const finger = useRef<HTMLSpanElement>(null);
  const stopDemo = useRef<(() => void) | null>(null);
  const swipe = useRef<{ id: number; x: number; y: number; on: boolean } | null>(null);
  /** The area as last set, for the demo and for keys pressed faster than a render. */
  const current = useRef(index);
  /** The cards' entrance and the bars' fill, played again for each new area. */
  const entrance = useRef<Animation[]>([]);
  const baseId = useId();

  const area = areas[index];
  const bars = RISK_BARS[area.risk];
  usePanelScroll(scroller, thumb);

  /** Plays the cards in one after the other, then fills the bars. */
  const playIn = (delay: number) => {
    const element = sheet.current;
    entrance.current.forEach((animation) => animation.cancel());
    entrance.current = [];
    if (!element || reducedMotion() || !rise) return;
    element.querySelectorAll<HTMLElement>('[data-enter]').forEach((part, order) => {
      entrance.current.push(
        part.animate(
          [
            { opacity: 0, transform: 'translateY(14px)' },
            { opacity: 1, transform: 'none' },
          ],
          { duration: 520, delay: delay + order * 90, easing: spring, fill: 'backwards' },
        ),
      );
    });
    element.querySelectorAll<HTMLElement>('[data-lit]').forEach((bar, order) => {
      entrance.current.push(
        bar.animate([{ transform: 'scaleY(0)' }, { transform: 'scaleY(1)' }], {
          duration: 420,
          delay: delay + 380 + order * 110,
          easing: spring,
          fill: 'backwards',
        }),
      );
    });
  };

  /** Shows an area; the reader's own changes are announced. */
  const select = (next: number, byReader: boolean) => {
    const to = (next + areas.length) % areas.length;
    if (to === current.current) return;
    current.current = to;
    setIndex(to);
    if (byReader) {
      onAreaChange?.(areas[to], to);
      const { area: name, title, risk, timeSpoken } = areas[to];
      setAnnouncement(`${name}: ${title}. ${RISK_LABEL[risk]}, within ${timeSpoken}.`);
    }
  };

  // A new area: back to the top of the sheet, then the cards come in and the bars fill.
  const shown = useRef(index);
  useEffect(() => {
    if (shown.current === index) return;
    shown.current = index;
    scroller.current?.scrollTo({ top: 0, behavior: reducedMotion() ? 'auto' : 'smooth' });
    playIn(120);
    // playIn reads the sheet's parts as they are now.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [index]);

  // The first time the phone is on screen the sheet rises in. Until then, a stage that starts
  // off screen keeps it lowered, so that it does not show and then drop away. The lit organ
  // breathes only while the stage is on screen.
  useEffect(() => {
    const stage = root.current;
    const element = sheet.current;
    if (!stage || !element) return;
    const still = reducedMotion();
    let first = rise;
    const rising: Animation[] = [];
    const observer = new IntersectionObserver(
      ([entry]) => {
        stage.toggleAttribute('data-live', entry.isIntersecting && !still);
        if (still || !first) return;
        if (!entry.isIntersecting) {
          stage.dataset.waiting = '';
          return;
        }
        first = false;
        delete stage.dataset.waiting;
        rising.push(
          element.animate([{ transform: 'translateY(100%)' }, { transform: 'none' }], {
            duration: 760,
            easing: spring,
            fill: 'backwards',
          }),
        );
        playIn(260);
      },
      { threshold: 0.3 },
    );
    observer.observe(stage);
    return () => {
      observer.disconnect();
      rising.forEach((animation) => animation.cancel());
      entrance.current.forEach((animation) => animation.cancel());
      delete stage.dataset.waiting;
      stage.removeAttribute('data-live');
    };
    // The observer reads the sheet's parts through playIn as they are when it fires.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [root]);

  // The demo: it opens the heart, scrolls the sheet down and back, swipes to the mouth, and taps
  // back to the lungs.
  const demo = autoplay && defaultOpen === undefined && playing && !keyboard && open;
  useEffect(() => {
    const stage = root.current;
    const disc = finger.current;
    if (!demo || !stage || !disc) return;
    const stop = runDemo(stage, disc, async ({ wait, moveTo, tap, slide }) => {
      await wait(1900);
      for (;;) {
        await moveTo(tabs.current[1]);
        await tap(() => select(1, false));
        await wait(2700);

        const list = scroller.current;
        if (list) {
          // The finger moves as far as the sheet scrolls, in the stage's own px.
          const room = list.scrollHeight - list.clientHeight;
          const by = Math.min(room, list.clientHeight * 0.42);
          await moveTo(list, { x: 0.55, y: 0.82 });
          await slide({ x: 0, y: -by }, 1000, (share) => {
            list.scrollTop = by * share;
          });
          await wait(1500);
          await slide({ x: 0, y: by }, 900, (share) => {
            list.scrollTop = by * (1 - share);
          });
          await wait(700);
        }

        await moveTo(hero.current, { x: 0.72, y: 0.5 });
        await slide({ x: -96, y: 0 }, 380, () => undefined);
        select(2, false);
        await wait(2800);

        await moveTo(tabs.current[0]);
        await tap(() => select(0, false));
        await wait(3400);
      }
    });
    stopDemo.current = stop;
    return stop;
    // The demo reads the elements and the latest area through refs as it plays.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demo, root]);

  /** The reader takes over: the demo stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    take();
  };

  const onTabKey = (event: KeyboardEvent<HTMLButtonElement>) => {
    const moves: Partial<Record<string, number>> = {
      ArrowRight: current.current + 1,
      ArrowLeft: current.current - 1,
      Home: 0,
      End: areas.length - 1,
    };
    const next = moves[event.key];
    if (next === undefined) return;
    event.preventDefault();
    const to = (next + areas.length) % areas.length;
    select(to, true);
    tabs.current[to]?.focus();
  };

  // The list scrolls by hand (see use-panel-scroll.ts), so its keys are handled here too.
  const onListKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const list = event.currentTarget;
    if (event.target !== list) return;
    const page = list.clientHeight * 0.85;
    const end = list.scrollHeight - list.clientHeight;
    const moves: Partial<Record<string, number>> = {
      ArrowDown: list.scrollTop + 40,
      ArrowUp: list.scrollTop - 40,
      PageDown: list.scrollTop + page,
      PageUp: list.scrollTop - page,
      ' ': list.scrollTop + (event.shiftKey ? -page : page),
      Home: 0,
      End: end,
    };
    const to = moves[event.key];
    if (to === undefined) return;
    event.preventDefault();
    list.scrollTo({
      top: Math.min(end, Math.max(0, to)),
      behavior: reducedMotion() ? 'auto' : 'smooth',
    });
  };

  // A sideways swipe on the figure switches areas; up and down still scroll the sheet.
  const onSwipeDown = (event: PointerEvent<HTMLDivElement>) => {
    swipe.current = { id: event.pointerId, x: event.clientX, y: event.clientY, on: false };
  };
  const onSwipeMove = (event: PointerEvent<HTMLDivElement>) => {
    const held = swipe.current;
    if (!held || held.id !== event.pointerId || held.on) return;
    const dx = event.clientX - held.x;
    const dy = event.clientY - held.y;
    if (Math.abs(dy) > 10 && Math.abs(dy) > Math.abs(dx)) swipe.current = null;
    else if (Math.abs(dx) > 10) {
      held.on = true;
      event.currentTarget.setPointerCapture(event.pointerId);
    }
  };
  const onSwipeUp = (event: PointerEvent<HTMLDivElement>) => {
    const held = swipe.current;
    swipe.current = null;
    if (!held || held.id !== event.pointerId || !held.on) return;
    const box = event.currentTarget.getBoundingClientRect();
    const scale = box.width / event.currentTarget.offsetWidth || 1;
    const dx = (event.clientX - held.x) / scale;
    if (Math.abs(dx) >= SWIPE) select(current.current + (dx < 0 ? 1 : -1), true);
  };

  const close = () => {
    setOpen(false);
    setAnnouncement('Summary closed.');
    onClose?.();
  };
  const show = (to: number) => {
    select(to, false);
    setOpen(true);
    setAnnouncement(`${areas[to].area}: ${areas[to].title}.`);
  };
  // Focus follows the sheet: onto the screen behind it when it closes, back to the tabs when
  // it opens again.
  const opened = useRef(open);
  useEffect(() => {
    if (opened.current === open) return;
    opened.current = open;
    if (open) tabs.current[current.current]?.focus();
    else reopen.current?.focus();
  }, [open]);

  const onFocus = (event: FocusEvent<HTMLDivElement>) => {
    if (event.target.matches(':focus-visible')) setKeyboard(true);
  };
  const onBlur = (event: FocusEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget)) setKeyboard(false);
  };

  return (
    <div
      ref={root}
      className={`${styles.stage} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocus={onFocus}
      onBlur={onBlur}
    >
      <div className={styles.phone} data-part="phone">
        <span className={styles.buttons} aria-hidden="true" />
        <div className={styles.bezel}>
          <div className={styles.screen}>
            <div className={styles.status} aria-hidden="true">
              <span className={styles.clock}>9:41</span>
              <span className={styles.island} />
              <StatusIcons className={styles.statusIcons} />
            </div>

            {/* The screen the sheet was opened from, shown in full while the sheet is closed. */}
            <div
              className={styles.behind}
              data-part="behind"
              data-open={open || undefined}
              inert={open}
            >
              <p className={styles.behindTitle}>Health map</p>
              <ul className={styles.behindList}>
                {areas.map((entry, place) => (
                  <li key={entry.id}>
                    <button
                      ref={place === index ? reopen : undefined}
                      type="button"
                      className={styles.row}
                      data-part="row"
                      data-risk={entry.risk}
                      onClick={() => show(place)}
                    >
                      <span>{entry.area}</span>
                      <span className={styles.rowRisk}>{RISK_LABEL[entry.risk]}</span>
                    </button>
                  </li>
                ))}
              </ul>
            </div>

            <section
              ref={sheet}
              className={styles.sheet}
              data-part="sheet"
              data-open={open || undefined}
              data-risk={area.risk}
              aria-label="Summary"
              inert={!open}
            >
              <div className={styles.top}>
                <button
                  type="button"
                  className={styles.close}
                  data-part="close"
                  aria-label="Close summary"
                  onClick={close}
                >
                  <CloseIcon className={styles.closeIcon} />
                </button>
                <div className={styles.tabs} data-part="tabs" role="tablist" aria-label="Body area">
                  {areas.map((entry, place) => (
                    <button
                      key={entry.id}
                      ref={(element) => {
                        tabs.current[place] = element;
                      }}
                      type="button"
                      role="tab"
                      id={`${baseId}-tab-${entry.id}`}
                      className={styles.tab}
                      data-part="tab"
                      aria-selected={place === index}
                      aria-controls={`${baseId}-panel`}
                      tabIndex={place === index ? 0 : -1}
                      onClick={() => select(place, true)}
                      onKeyDown={onTabKey}
                    >
                      {entry.tab}
                    </button>
                  ))}
                </div>
              </div>

              <div
                ref={scroller}
                id={`${baseId}-panel`}
                className={styles.scroller}
                role="tabpanel"
                tabIndex={0}
                aria-labelledby={`${baseId}-tab-${area.id}`}
                onKeyDown={onListKey}
              >
                <div
                  ref={hero}
                  className={styles.hero}
                  data-part="figure"
                  onPointerDown={onSwipeDown}
                  onPointerMove={onSwipeMove}
                  onPointerUp={onSwipeUp}
                  onPointerCancel={() => {
                    swipe.current = null;
                  }}
                >
                  {/* The figure, which pans down to the face for the mouth. */}
                  <div className={styles.pan} data-area={area.id}>
                    <span className={styles.body} style={boxVars(BODY_BOX)} aria-hidden="true">
                      <Body />
                    </span>
                    {areas.map((entry) => {
                      const Drawing = LIT[entry.id];
                      return (
                        <span
                          key={entry.id}
                          className={styles.lit}
                          data-part="lit"
                          data-on={entry.id === area.id || undefined}
                          style={boxVars(LIT_BOX[entry.id])}
                          aria-hidden="true"
                        >
                          <span className={styles.breath}>
                            <Drawing />
                          </span>
                        </span>
                      );
                    })}
                  </div>
                  <span className={styles.shade} aria-hidden="true" />
                  <span className={styles.fade} aria-hidden="true" />
                </div>

                <p className={styles.area} data-part="area">
                  {area.area}
                </p>
                <h3 className={styles.title} data-part="title">
                  {area.title}
                </h3>

                <div className={styles.card} data-part="risk-card" data-enter="">
                  <div className={styles.cardHead}>
                    <div>
                      <p className={styles.label}>Risk likelihood</p>
                      <p className={styles.chip} data-part="chip">
                        {RISK_LABEL[area.risk]}
                      </p>
                    </div>
                    <span
                      className={styles.signal}
                      data-part="signal"
                      role="img"
                      aria-label={`${bars} of 4 bars`}
                    >
                      {[0, 1, 2, 3].map((bar) => (
                        <span
                          key={`${area.id}-${bar}`}
                          className={styles.bar}
                          data-lit={bar < bars ? '' : undefined}
                        />
                      ))}
                    </span>
                  </div>
                  <p className={styles.text}>{area.likelihood}</p>
                </div>

                <div className={styles.card} data-part="time-card" data-enter="">
                  <div className={styles.cardHead}>
                    <div>
                      <p className={styles.label}>Est. time to risk</p>
                      <p className={styles.time} data-part="time">
                        {area.time}
                      </p>
                    </div>
                    <span className={styles.clockDisc} data-part="clock" aria-hidden="true">
                      <ClockIcon className={styles.clockIcon} />
                    </span>
                  </div>
                  <p className={styles.text}>{area.urgency}</p>
                </div>

                <p className={styles.note} data-part="note" data-enter="">
                  This summary is general information, not a diagnosis.
                </p>
              </div>
              <span className={styles.bottom} aria-hidden="true" />
              <span ref={thumb} className={styles.thumb} aria-hidden="true" />
            </section>

            <span className={styles.home} aria-hidden="true" />
          </div>
        </div>
      </div>

      <p className={styles.hidden} aria-live="polite">
        {announcement}
      </p>
      <div className={styles.pointerLayer}>
        <DemoTouch ref={finger} />
      </div>
    </div>
  );
}
```

### health-sheet/health-sheet.module.css

```css
/* Health sheet (health-sheet.tsx): a silver phone on a light stage, its screen showing a light
   bottom sheet with a pale figure; at night a graphite phone on a near-black stage, the sheet
   warm and dark, the figure dark and translucent. The lit organs are a drawing that never
   changes: the same orange in both. The phone is drawn in points, as a phone's screen is
   designed: --pt is one point in px (0.647 at full size, less on a stage too narrow for it), so
   the phone scales as one picture.

   Memory. The stage is the one layout boundary, of a fixed height. What moves is moved by
   transforms and opacity on boxes of a fixed size: the sheet, the figure, the lit organs (whose
   breath runs only while the stage is on screen), the cards and the bars. The drawings are SVGs
   that never change. The biggest layer is the list, which is repainted as it scrolls, so it is
   the only one the size of the sheet: it paints the sheet's background too, and the sheet itself
   paints nothing. The figure is a layer of its own, cut to the body, which the pan moves without
   repainting anything. And every layer inside the list keeps its full size as the list scrolls
   (see .scroller), so that none is ever cut and sized anew. */

.stage {
  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root
     inherits from the page, or the one the colorScheme prop sets (below). */
  /* The stage and its edge. */
  --surface: #f7f6fb;
  --line: rgb(0 0 0 / 6%);
  /* The phone's frame, silver or graphite: its body, the four stops of its sheen, its edge, the
     shine inside the edge, and the side buttons. */
  --surface-2: #d8dadd;
  --frame-light: #f5f6f7;
  --frame-mid: #eff0f2;
  --frame-dark: #c8cacd;
  --frame-edge: rgb(0 0 0 / 22%);
  --frame-shine: rgb(255 255 255 / 55%);
  --frame-button: #cfd1d4;
  /* The phone's drop and the shade right under it. */
  --shadow: rgb(30 20 40 / 24%);
  --shadow-near: rgb(30 20 40 / 7%);
  /* The screen: its text, the labels and tabs, and the focus ring with what would sit on it. */
  --ink: #1f1916;
  --ink-muted: #6f615b;
  --accent: #0a7aff;
  --accent-ink: #fff;
  /* The screen the sheet was opened from, in full and then pushed back, and its rows. */
  --behind: #f2f2f7;
  --behind-open: #e4e4e9;
  --row: #fff;
  /* The sheet's background. By day a light wash, with a soft peach glow low down, is painted over
     the night's warm dark and its glow; at night both day layers are transparent. */
  --veil-top: #fcfaf9;
  --veil-bottom: #fcede5;
  --day-glow: rgb(255 120 60 / 13%);
  --day-glow-edge: rgb(255 120 60 / 0%);
  /* The colours the figure and the copy fade into: the sheet's top, under the figure, and its
     bottom edge (under the home indicator); each with its clear end. */
  --sheet-top: #fcfaf9;
  --sheet-top-clear: rgb(252 250 249 / 0%);
  --sheet-under: #fcfaf9;
  --sheet-under-clear: rgb(252 250 249 / 0%);
  --sheet-end: rgb(252 237 229 / 96%);
  --sheet-end-clear: rgb(252 237 229 / 0%);
  /* The figure: its skin (the gradient's two stops), the creases at the arms, the organs, the
     windpipe, the lips, and the heart, which is the body's own colour seen through it. */
  --skin-top: #ebe0da;
  --skin-bottom: #e9ddd6;
  --crease: #c4b0a5;
  --organ: #dccbc1;
  --airway: #d8c6bc;
  --lips: #dcbcae;
  --heart: #e2d4cc;
  /* The sheet's words: the close cross, the area, the title, the time, the cards' text, the note,
     and the clock. */
  --close: #6c605b;
  --area: #6f615b;
  --title: #1f1916;
  --time: #1f1916;
  --text: #5c4f4a;
  --note: #6c5e58;
  --clock-ink: #4d3f39;
  /* The tabs' track and the selected tab, with its lift; the list's scroll bar. */
  --tabs: rgb(118 100 92 / 12%);
  --tab-on: #fff;
  --tab-shadow: rgb(60 30 20 / 16%);
  --thumb: rgb(60 30 20 / 30%);
  /* The cards: their edge, their fill (top and bottom), the rule under their heads, their lift. */
  --card-edge: rgb(110 60 40 / 10%);
  --card-top: rgb(255 255 255 / 92%);
  --card-bottom: rgb(255 255 255 / 84%);
  --card-rule: rgb(110 60 40 / 10%);
  --card-shadow: rgb(110 50 20 / 6%);
  /* The risks, by level: the colour (the chip's words and the lit bars), and its wash, the same
     in both schemes. */
  --risk-low: #9a5400;
  --risk-low-wash: rgb(255 170 40 / 15%);
  --risk-medium: #b93d0b;
  --risk-medium-wash: rgb(255 92 20 / 16%);
  --risk-high: #b8290d;
  --risk-high-wash: rgb(255 60 30 / 17%);
  /* The bars' disc (its middle, its edge, its glow) and an unlit bar; the clock's disc and glow. */
  --signal-core: rgb(255 90 20 / 16%);
  --signal-edge: rgb(255 90 20 / 6%);
  --signal-glow: rgb(255 80 10 / 10%);
  --bar-off: rgb(230 80 20 / 22%);
  --disc: rgb(110 60 40 / 8%);
  --disc-glow: rgb(255 240 230 / 0%);
  /* The home indicator, and the demo's finger: its disc (middle and edge), ring and shadow. */
  --home: #1f1916;
  --touch: rgb(40 25 20 / 30%);
  --touch-edge: rgb(40 25 20 / 15%);
  --touch-ring: rgb(40 25 20 / 30%);
  --touch-shadow: rgb(0 0 0 / 15%);
  position: relative;
  display: grid;
  place-items: center;
  height: 620px;
  contain: strict;
  container-type: inline-size;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  font:
    400 14px/1.3 Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .stage {
    --surface: light-dark(#f7f6fb, #111113);
    --line: light-dark(rgb(0 0 0 / 6%), rgb(255 255 255 / 7%));
    --surface-2: light-dark(#d8dadd, #2c2d30);
    --frame-light: light-dark(#f5f6f7, #4a4b4f);
    --frame-mid: light-dark(#eff0f2, #3e3f43);
    --frame-dark: light-dark(#c8cacd, #222326);
    --frame-edge: light-dark(rgb(0 0 0 / 22%), rgb(0 0 0 / 60%));
    --frame-shine: light-dark(rgb(255 255 255 / 55%), rgb(255 255 255 / 13%));
    --frame-button: light-dark(#cfd1d4, #35363a);
    --shadow: light-dark(rgb(30 20 40 / 24%), rgb(0 0 0 / 60%));
    --shadow-near: light-dark(rgb(30 20 40 / 7%), rgb(0 0 0 / 35%));
    --ink: light-dark(#1f1916, #fff);
    --ink-muted: light-dark(#6f615b, #a3958f);
    --accent: light-dark(#0a7aff, #8ab4ff);
    --accent-ink: light-dark(#fff, #000);
    --behind: light-dark(#f2f2f7, #121212);
    --behind-open: light-dark(#e4e4e9, #191818);
    --row: light-dark(#fff, #1f1d1c);
    --veil-top: light-dark(#fcfaf9, transparent);
    --veil-bottom: light-dark(#fcede5, transparent);
    --day-glow: light-dark(rgb(255 120 60 / 13%), transparent);
    --day-glow-edge: light-dark(rgb(255 120 60 / 0%), transparent);
    --sheet-top: light-dark(#fcfaf9, #0b0400);
    --sheet-top-clear: light-dark(rgb(252 250 249 / 0%), rgb(11 4 0 / 0%));
    --sheet-under: light-dark(#fcfaf9, #0c0501);
    --sheet-under-clear: light-dark(rgb(252 250 249 / 0%), rgb(12 5 1 / 0%));
    --sheet-end: light-dark(rgb(252 237 229 / 96%), rgb(59 27 17 / 96%));
    --sheet-end-clear: light-dark(rgb(252 237 229 / 0%), rgb(59 27 17 / 0%));
    --skin-top: light-dark(#ebe0da, #3b2b23);
    --skin-bottom: light-dark(#e9ddd6, #37271f);
    --crease: light-dark(#c4b0a5, #1a0c07);
    --organ: light-dark(#dccbc1, #2c1e18);
    --airway: light-dark(#d8c6bc, #30211a);
    --lips: light-dark(#dcbcae, #442b21);
    --heart: light-dark(#e2d4cc, #3b2822);
    --close: light-dark(#6c605b, #cfc9c6);
    --area: light-dark(#6f615b, #847774);
    --title: light-dark(#1f1916, #fffcf6);
    --time: light-dark(#1f1916, #fff8ee);
    --text: light-dark(#5c4f4a, #baa69e);
    --note: light-dark(#6c5e58, #7d6f6a);
    --clock-ink: light-dark(#4d3f39, #fff3e8);
    --tabs: light-dark(rgb(118 100 92 / 12%), rgb(255 240 232 / 6%));
    --tab-on: light-dark(#fff, rgb(255 240 232 / 14%));
    --tab-shadow: light-dark(rgb(60 30 20 / 16%), transparent);
    --thumb: light-dark(rgb(60 30 20 / 30%), rgb(255 240 232 / 34%));
    --card-edge: light-dark(rgb(110 60 40 / 10%), rgb(255 226 212 / 6%));
    --card-top: light-dark(rgb(255 255 255 / 92%), rgb(255 226 212 / 8%));
    --card-bottom: light-dark(rgb(255 255 255 / 84%), rgb(255 226 212 / 5%));
    --card-rule: light-dark(rgb(110 60 40 / 10%), rgb(255 226 212 / 8%));
    --card-shadow: light-dark(rgb(110 50 20 / 6%), transparent);
    --risk-low: light-dark(#9a5400, #ffb02e);
    --risk-medium: light-dark(#b93d0b, #ff7a2e);
    --risk-high: light-dark(#b8290d, #ff5a2e);
    --signal-core: light-dark(rgb(255 90 20 / 16%), rgb(255 90 20 / 28%));
    --signal-edge: light-dark(rgb(255 90 20 / 6%), rgb(255 90 20 / 10%));
    --signal-glow: light-dark(rgb(255 80 10 / 10%), rgb(255 80 10 / 22%));
    --bar-off: light-dark(rgb(230 80 20 / 22%), rgb(255 80 20 / 26%));
    --disc: light-dark(rgb(110 60 40 / 8%), rgb(255 240 230 / 14%));
    --disc-glow: light-dark(rgb(255 240 230 / 0%), rgb(255 240 230 / 6%));
    --home: light-dark(#1f1916, #fff);
    --touch: light-dark(rgb(40 25 20 / 30%), rgb(255 255 255 / 55%));
    --touch-edge: light-dark(rgb(40 25 20 / 15%), rgb(255 255 255 / 28%));
    --touch-ring: light-dark(rgb(40 25 20 / 30%), rgb(255 255 255 / 70%));
    --touch-shadow: light-dark(rgb(0 0 0 / 15%), rgb(0 0 0 / 30%));
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.stage[data-color-scheme='light'] {
  color-scheme: light;
}
.stage[data-color-scheme='dark'] {
  color-scheme: dark;
}
.stage[data-color-scheme='system'] {
  color-scheme: light dark;
}
.stage *,
.stage *::before,
.stage *::after {
  box-sizing: border-box;
}
.stage :where(button) {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
}
.stage :where(p, h3, ul) {
  margin: 0;
  padding: 0;
}

/* The phone: a silver frame (graphite at night), a black bezel and the screen, 393 by 852
   points. */
.phone {
  --pt: min(0.647px, calc((100cqw - 24px) / 425.7));
  position: relative;
  width: calc(var(--pt) * 425.7);
  height: calc(var(--pt) * 884);
  border-radius: calc(var(--pt) * 62);
  background: linear-gradient(
    140deg,
    var(--frame-light),
    var(--surface-2) 34%,
    var(--frame-mid) 62%,
    var(--frame-dark)
  );
  box-shadow:
    inset 0 0 0 0.6px var(--frame-edge),
    inset 0 0 0 calc(var(--pt) * 2) var(--frame-shine),
    0 26px 48px -20px var(--shadow),
    0 3px 8px var(--shadow-near);
}
/* The side buttons: the action button and the volume on the left, the side button on the right. */
.buttons::before,
.buttons::after {
  position: absolute;
  width: calc(var(--pt) * 3.4);
  content: '';
}
.buttons::before {
  top: calc(var(--pt) * 137);
  left: calc(var(--pt) * -3);
  height: calc(var(--pt) * 211);
  border-radius: calc(var(--pt) * 2) 0 0 calc(var(--pt) * 2);
  background: linear-gradient(
    var(--frame-button) 0 calc(var(--pt) * 37),
    transparent 0 calc(var(--pt) * 70),
    var(--frame-button) 0 calc(var(--pt) * 131),
    transparent 0 calc(var(--pt) * 152),
    var(--frame-button) 0
  );
}
.buttons::after {
  top: calc(var(--pt) * 224);
  right: calc(var(--pt) * -3);
  height: calc(var(--pt) * 107);
  border-radius: 0 calc(var(--pt) * 2) calc(var(--pt) * 2) 0;
  background: var(--frame-button);
}
.bezel {
  position: absolute;
  inset: calc(var(--pt) * 4.5);
  border-radius: calc(var(--pt) * 57.5);
  background: #0b0b0b;
}
.screen {
  position: absolute;
  inset: calc(var(--pt) * 11.8);
  overflow: hidden;
  border-radius: calc(var(--pt) * 46);
  background: #000;
  color: var(--ink);
}

/* The status bar: white over the black that shows round the screen behind while the sheet is up,
   in the screen's ink once that screen is back in full. */
.status {
  position: absolute;
  z-index: 3;
  top: 0;
  right: 0;
  left: 0;
  height: calc(var(--pt) * 54);
  color: #fff;
  pointer-events: none;
  transition: color 0.6s ease;
}
.screen:has(> .behind:not([data-open])) > .status {
  color: var(--ink);
}
.clock {
  position: absolute;
  top: calc(var(--pt) * 30);
  left: calc(var(--pt) * 56);
  font-size: calc(var(--pt) * 15);
  font-weight: 600;
  line-height: 1;
  transform: translate(-50%, -50%);
}
.island {
  position: absolute;
  top: calc(var(--pt) * 15);
  left: 50%;
  width: calc(var(--pt) * 98);
  height: calc(var(--pt) * 28);
  margin-left: calc(var(--pt) * -49);
  border-radius: calc(var(--pt) * 14);
  background: #161616;
}
.statusIcons {
  position: absolute;
  top: calc(var(--pt) * 23.5);
  right: calc(var(--pt) * 24);
  width: calc(var(--pt) * 70);
  height: calc(var(--pt) * 13);
}

/* The screen behind the sheet: pushed back while the sheet is up (only its top edge shows above
   it), in full once the sheet is closed. */
.behind {
  position: absolute;
  inset: 0;
  padding: calc(var(--pt) * 92) calc(var(--pt) * 20) 0;
  border-radius: calc(var(--pt) * 46);
  background: var(--behind);
  transform-origin: 50% 0;
  transition:
    transform 0.6s cubic-bezier(0.2, 0.9, 0.25, 1),
    background-color 0.6s ease;
}
.behind[data-open] {
  background: var(--behind-open);
  transform: translateY(calc(var(--pt) * 58)) scale(0.93);
}
.behindTitle {
  font-size: calc(var(--pt) * 30);
  font-weight: 700;
  line-height: 1.2;
}
.behindList {
  display: flex;
  flex-direction: column;
  gap: calc(var(--pt) * 10);
  margin-top: calc(var(--pt) * 22) !important;
  list-style: none;
}
.row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  height: calc(var(--pt) * 54);
  padding: 0 calc(var(--pt) * 16) !important;
  border-radius: calc(var(--pt) * 14);
  background: var(--row) !important;
  font-size: calc(var(--pt) * 16);
  cursor: pointer;
}
.rowRisk {
  font-size: calc(var(--pt) * 14);
  color: var(--risk);
}
.row:focus-visible,
.close:focus-visible,
.tab:focus-visible,
.scroller:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.scroller:focus-visible {
  outline-offset: -2px;
}

/* Each risk's colour. */
.row[data-risk='low'],
.sheet[data-risk='low'] {
  --risk: var(--risk-low);
  --risk-wash: var(--risk-low-wash);
}
.row[data-risk='medium'],
.sheet[data-risk='medium'] {
  --risk: var(--risk-medium);
  --risk-wash: var(--risk-medium-wash);
}
.row[data-risk='high'],
.sheet[data-risk='high'] {
  --risk: var(--risk-high);
  --risk-wash: var(--risk-high-wash);
}

/* The sheet: it rises in and clips the list, which paints its background. */
.sheet {
  position: absolute;
  z-index: 2;
  top: calc(var(--pt) * 74);
  right: 0;
  bottom: 0;
  left: 0;
  overflow: hidden;
  /* The same radius at every corner, so that clipping the scrolling list needs no mask (the
     screen's rounder corners hide the bottom ones). */
  border-radius: calc(var(--pt) * 38);
  transition: transform 0.6s cubic-bezier(0.2, 0.9, 0.25, 1);
}
.sheet:not([data-open]),
.stage[data-waiting] .sheet {
  transform: translateY(100%);
}

/* The close button and the tabs stay at the top while the sheet scrolls under them. */
.top {
  position: absolute;
  z-index: 2;
  top: 0;
  right: 0;
  left: 0;
  height: calc(var(--pt) * 82);
  pointer-events: none;
  /* A layer of its own: switching tabs repaints it alone. */
  will-change: transform;
}
.close {
  position: absolute;
  top: calc(var(--pt) * 26);
  left: calc(var(--pt) * 15);
  display: grid;
  place-items: center;
  width: calc(var(--pt) * 36);
  height: calc(var(--pt) * 36);
  border-radius: 50%;
  color: var(--close);
  cursor: pointer;
  pointer-events: auto;
}
.closeIcon {
  width: calc(var(--pt) * 21);
  height: calc(var(--pt) * 21);
}
.tabs {
  position: absolute;
  top: calc(var(--pt) * 30);
  right: calc(var(--pt) * 16);
  display: flex;
  gap: calc(var(--pt) * 2);
  padding: calc(var(--pt) * 3);
  border-radius: calc(var(--pt) * 15);
  background: var(--tabs);
  pointer-events: auto;
}
.tab {
  height: calc(var(--pt) * 22);
  padding: 0 calc(var(--pt) * 10) !important;
  border-radius: calc(var(--pt) * 11);
  color: var(--ink-muted);
  font-size: calc(var(--pt) * 11.5);
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.25s ease,
    color 0.25s ease,
    box-shadow 0.25s ease;
}
/* The selected tab: by day white, lifted off the track, as a segmented control's. */
.tab[aria-selected='true'] {
  background: var(--tab-on);
  box-shadow: 0 calc(var(--pt) * 1) calc(var(--pt) * 3) var(--tab-shadow);
  color: var(--ink);
}

/* The list, scrolled by hand (use-panel-scroll.ts): a hidden-overflow box on a layer of its own,
   repainted in place as it scrolls. It paints the sheet's background, which stays put as the list
   scrolls: at night dark at the top, warming to a deep red-brown below, with a warm glow in the
   middle of its lower half; by day, painted over that, near-white at the top, warming to a pale
   peach, with a soft peach glow. */
.scroller {
  position: absolute;
  /* A pixel wider on each side than the sheet, which clips it: its layer, rounded to whole
     pixels, then always reaches the sheet's edges. The padding keeps the list's width. */
  inset: 0 -1px;
  padding: 0 1px calc(var(--pt) * 46);
  overflow: hidden;
  background:
    radial-gradient(52% 30% at 50% 80%, var(--day-glow), var(--day-glow-edge)),
    linear-gradient(var(--veil-top) 43%, var(--veil-bottom)),
    radial-gradient(
      52% 30% at 50% 80%,
      rgb(200 62 18 / 23%),
      rgb(200 62 18 / 22%) 20%,
      rgb(200 62 18 / 14%) 50%,
      rgb(200 62 18 / 7%) 75%,
      rgb(200 62 18 / 0%)
    ),
    linear-gradient(
      #070300 0,
      #0d0601 17%,
      #120701 31%,
      #0b0400 43%,
      #120601 48%,
      #190801 54%,
      #210b02 60%,
      #2b0f04 69%,
      #2d1104 76%,
      #2e1205 83%,
      #2b1002 92%,
      #2a1003
    );
  will-change: transform;
  /* An identity transform keeps the layers inside it (the figure, the shades over it, the cards
     as they come in) at their full size as they scroll out of view, instead of cut to what
     shows, which would size them anew on every frame of a scroll. */
  transform: translateY(0);
}
/* Where the list is, while it moves: a thin bar over its right edge. */
.thumb {
  position: absolute;
  z-index: 3;
  top: 0;
  right: calc(var(--pt) * 4);
  width: calc(var(--pt) * 3);
  height: 0;
  border-radius: calc(var(--pt) * 2);
  background: var(--thumb);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}
.thumb[data-shown] {
  opacity: 1;
}

/* The figure, which pans down for the mouth, and its lit organs. */
.hero {
  position: relative;
  height: calc(var(--pt) * 318);
  /* The figure runs on behind the area's name and the title, fading out under them. */
  margin-bottom: calc(var(--pt) * -56);
  overflow: hidden;
  touch-action: pan-y;
}
.pan {
  position: absolute;
  top: calc(var(--pt) * -147);
  left: 50%;
  width: calc(var(--pt) * 393);
  height: calc(var(--pt) * 480);
  margin-left: calc(var(--pt) * -196.5);
}
/* The drawing, and a lit copy of each organ group, each on a box of its own (its place and size, in
   points, come from the component). They move down together for the mouth. */
.body,
.lit {
  position: absolute;
  top: calc(var(--y) * var(--pt));
  left: calc(var(--x) * var(--pt));
  width: calc(var(--w) * var(--pt));
  height: calc(var(--h) * var(--pt));
  transition: transform 0.8s cubic-bezier(0.25, 0.9, 0.25, 1);
}
.pan[data-area='mouth'] > .body,
.pan[data-area='mouth'] > .lit {
  transform: translateY(calc(var(--pt) * 122));
}
/* The drawing never changes: on a layer of its own, cut to the body, the pan only moves it. */
.body {
  will-change: transform;
}
.figure {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}
/* The figure's colours (body.tsx): pale by day, dark and translucent at night. The lit organs keep
   their own orange. */
.skinTop {
  stop-color: var(--skin-top);
}
.skinBottom {
  stop-color: var(--skin-bottom);
}
.crease {
  stroke: var(--crease);
}
.organ {
  fill: var(--organ);
}
.airway {
  fill: var(--airway);
}
.lips {
  fill: var(--lips);
}
.heart {
  fill: var(--heart);
  stroke: var(--heart);
}
/* A lit organ fades in when its area is shown. */
.lit {
  opacity: 0;
  transition:
    opacity 0.55s ease,
    transform 0.8s cubic-bezier(0.25, 0.9, 0.25, 1);
}
.lit[data-on] {
  opacity: 1;
}
/* The lit organ breathes: its glow swells and softens, slowly. */
.breath {
  display: block;
  width: 100%;
  height: 100%;
  animation: breathe 5.2s ease-in-out infinite paused;
}
.stage[data-live] .lit[data-on] .breath {
  animation-play-state: running;
}
@keyframes breathe {
  0%,
  100% {
    opacity: 0.84;
    transform: scale(0.985);
  }
  50% {
    opacity: 1;
    transform: scale(1.025);
  }
}
/* The figure fades into the sheet at the top and under it, where the copy begins. */
.shade {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  height: calc(var(--pt) * 64);
  background: linear-gradient(var(--sheet-top) 18%, var(--sheet-top-clear));
  pointer-events: none;
}
.fade {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: calc(var(--pt) * 92);
  background: linear-gradient(var(--sheet-under-clear), var(--sheet-under) 92%);
  pointer-events: none;
}

.area {
  position: relative;
  margin-top: calc(var(--pt) * 4) !important;
  color: var(--area);
  font-size: calc(var(--pt) * 14.6);
  line-height: calc(var(--pt) * 18);
  text-align: center;
}
.title {
  position: relative;
  margin-top: calc(var(--pt) * 6) !important;
  font-size: calc(var(--pt) * 22.5);
  font-weight: 500;
  line-height: calc(var(--pt) * 28);
  text-align: center;
  color: var(--title);
}

/* A card: a label and its value over a line, then a few sentences. */
.card {
  margin: calc(var(--pt) * 18) calc(var(--pt) * 18) 0;
  padding: 0 calc(var(--pt) * 19) calc(var(--pt) * 17);
  border: 1px solid var(--card-edge);
  border-radius: calc(var(--pt) * 16);
  background: linear-gradient(var(--card-top), var(--card-bottom));
  box-shadow: 0 calc(var(--pt) * 2) calc(var(--pt) * 10) var(--card-shadow);
}
.title + .card {
  margin-top: calc(var(--pt) * 22);
}
.cardHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(var(--pt) * 12);
  padding: calc(var(--pt) * 15) 0 calc(var(--pt) * 13);
  border-bottom: 1px solid var(--card-rule);
}
.label {
  color: var(--ink-muted);
  font-size: calc(var(--pt) * 14.3);
  line-height: calc(var(--pt) * 18);
}
.chip {
  display: inline-block;
  height: calc(var(--pt) * 23);
  margin-top: calc(var(--pt) * 7) !important;
  padding: 0 calc(var(--pt) * 9) !important;
  border-radius: calc(var(--pt) * 7);
  background: var(--risk-wash);
  box-shadow: 0 0 calc(var(--pt) * 14) var(--risk-wash);
  color: var(--risk);
  font-size: calc(var(--pt) * 12.5);
  font-weight: 500;
  line-height: calc(var(--pt) * 23);
}
.time {
  margin-top: calc(var(--pt) * 5) !important;
  color: var(--time);
  font-size: calc(var(--pt) * 21);
  font-weight: 500;
  line-height: calc(var(--pt) * 26);
}
/* The risk's bars, in a glowing disc: they fill from the left up to the risk. */
.signal {
  display: flex;
  flex: none;
  align-items: flex-end;
  justify-content: center;
  gap: calc(var(--pt) * 2.4);
  width: calc(var(--pt) * 34);
  height: calc(var(--pt) * 34);
  padding-bottom: calc(var(--pt) * 7);
  border-radius: 50%;
  background: radial-gradient(circle, var(--signal-core), var(--signal-edge) 72%);
  box-shadow: 0 0 calc(var(--pt) * 16) var(--signal-glow);
}
.bar {
  width: calc(var(--pt) * 3.2);
  border-radius: calc(var(--pt) * 1);
  background: var(--bar-off);
  transform-origin: 50% 100%;
}
.bar:nth-child(1) {
  height: calc(var(--pt) * 8.5);
}
.bar:nth-child(2) {
  height: calc(var(--pt) * 12.3);
}
.bar:nth-child(3) {
  height: calc(var(--pt) * 16.1);
}
.bar:nth-child(4) {
  height: calc(var(--pt) * 19.9);
}
.bar[data-lit] {
  background: var(--risk);
  box-shadow: 0 0 calc(var(--pt) * 4) var(--risk-wash);
}
.clockDisc {
  display: grid;
  flex: none;
  place-items: center;
  width: calc(var(--pt) * 36);
  height: calc(var(--pt) * 36);
  border-radius: 50%;
  background: var(--disc);
  box-shadow: 0 0 calc(var(--pt) * 12) var(--disc-glow);
}
.clockIcon {
  width: calc(var(--pt) * 25);
  height: calc(var(--pt) * 25);
  color: var(--clock-ink);
}
.text {
  margin-top: calc(var(--pt) * 12) !important;
  color: var(--text);
  font-size: calc(var(--pt) * 14.6);
  line-height: calc(var(--pt) * 21.5);
}
.note {
  margin: calc(var(--pt) * 22) calc(var(--pt) * 24) 0 !important;
  color: var(--note);
  font-size: calc(var(--pt) * 12);
  line-height: calc(var(--pt) * 17);
  text-align: center;
}
/* The bottom edge, where the sheet's text fades under the home indicator. */
.bottom {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: calc(var(--pt) * 56);
  background: linear-gradient(var(--sheet-end-clear), var(--sheet-end) 70%);
  pointer-events: none;
}
.home {
  position: absolute;
  z-index: 4;
  bottom: calc(var(--pt) * 8);
  left: 50%;
  width: calc(var(--pt) * 134);
  height: calc(var(--pt) * 5);
  margin-left: calc(var(--pt) * -67);
  border-radius: calc(var(--pt) * 3);
  background: var(--home);
}

/* The demo's finger moves over the whole stage, in a box of its own. */
.pointerLayer {
  position: absolute;
  z-index: 10;
  inset: 0;
  contain: strict;
  pointer-events: none;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  /* On a layer of its own, so that a new announcement repaints nothing else. */
  will-change: opacity;
}

@media (prefers-reduced-motion: reduce) {
  .status,
  .behind,
  .sheet,
  .body,
  .lit,
  .thumb,
  .tab {
    transition: none;
  }
  .breath {
    animation: none;
  }
}
```

### health-sheet/areas.ts

```tsx
/** How likely the issue is to matter: it sets the chip's words and how many bars fill. */
export type Risk = 'low' | 'medium' | 'high';

/** One area of the body the sheet can show. */
export type Area = {
  id: 'lungs' | 'heart' | 'mouth';
  /** The tab's label. */
  tab: string;
  /** The line over the title: "Lungs & Airways". */
  area: string;
  /** The issue: the sheet's title. */
  title: string;
  risk: Risk;
  /** What the risk means, in a few sentences. */
  likelihood: string;
  /** How soon it may matter: "<3 months". */
  time: string;
  /** The same, read aloud: "less than 3 months". */
  timeSpoken: string;
  /** What that time means, in a few sentences. */
  urgency: string;
};

export const RISK_LABEL: Record<Risk, string> = {
  low: 'Low Risk',
  medium: 'Medium Risk',
  high: 'High Risk',
};

/** How many of the four bars each risk fills. */
export const RISK_BARS: Record<Risk, number> = { low: 1, medium: 3, high: 4 };

/** Sample findings, written as general information rather than advice. */
export const AREAS: Area[] = [
  {
    id: 'lungs',
    tab: 'Lungs',
    area: 'Lungs & Airways',
    title: 'Airway Inflammation',
    risk: 'medium',
    likelihood:
      'This issue has a fair chance of affecting your overall health. It’s not urgent, but it’s worth acting on. Irritation in the mouth and throat, like a cough that lingers, may travel down to your airways and slowly add to this risk over the coming months.',
    time: '<3 months',
    timeSpoken: 'less than 3 months',
    urgency:
      'There’s some urgency to act. Left as it is, this issue may lead to a related health problem within 3 months. Talking it over with a professional soon is a sensible next step to take.',
  },
  {
    id: 'heart',
    tab: 'Heart',
    area: 'Heart & Vessels',
    title: 'Raised Blood Pressure',
    risk: 'low',
    likelihood:
      'This issue has a small chance of affecting your overall health. Nothing here points to an urgent problem, and everyday habits, like rest and regular movement, often make a real difference. It’s still worth keeping an eye on over the coming months.',
    time: '6–12 months',
    timeSpoken: '6 to 12 months',
    urgency:
      'There’s no rush. If nothing changes, this issue may lead to a related health problem within a year. Your next routine check-up is a good moment to bring it up.',
  },
  {
    id: 'mouth',
    tab: 'Mouth',
    area: 'Mouth & Gums',
    title: 'Gum Inflammation',
    risk: 'high',
    likelihood:
      'This issue has a high chance of affecting your overall health. It’s worth acting on soon. Changes in your gums, like redness or bleeding when you brush, may allow bacteria to enter your system and steadily raise this risk over time.',
    time: '<1 month',
    timeSpoken: 'less than a month',
    urgency:
      'There’s a clear urgency to act. If untreated, this issue may lead to a related health problem within weeks. Asking a dentist for advice soon is a good idea.',
  },
];
```

### health-sheet/body.tsx

```tsx
import { useId } from 'react';
import styles from './health-sheet.module.css';

/*
 * The body: a translucent figure from the head to the waist, with its organs drawn in it, and a
 * lit copy of each organ group on a box of its own, just big enough for it and its halo. Lighting
 * an organ fades its lit copy in (an HTML box's opacity): the drawings themselves never change.
 * Coordinates are in points, with 0 near the top of the sheet and the head above it, out of view
 * until the figure pans down.
 *
 * The figure's own colours are tokens in the stylesheet, set by class: pale by day, dark at night.
 * The lit organs are drawn in their orange, the same in both.
 */

/** The figure's box, in points: x from 0 to 393, y from -150 to 330. */
export const VIEW = { top: -150 };

/**
 * The drawing's box in the figure, in points: x, y, width and height. It is cut to the body, from
 * just above the head to the lowest point that ever shows, so that its layer is no bigger than it.
 */
export const BODY_BOX = [60, -125, 273, 441] as const;

/** Each lit drawing's box in the figure, in points: x, y, width and height. */
export const LIT_BOX = {
  lungs: [110, 34, 173, 206],
  heart: [158, 118, 92, 130],
  mouth: [155, -47, 84, 52],
} as const;
const boxOf = (area: keyof typeof LIT_BOX) => LIT_BOX[area].join(' ');

const TORSO =
  'M196.5-123C163.5-123 138.5-93 138.5-53c0 28 11 50 26 62l2 49c-6.5 12-14.5 18-26.5 21.5-20 4.5-40 9.5-54 18.5-9 8-12 18-13 32-4 20-7 42-8 66l-3 134h269l-3-134c-1-24-4-46-8-66-1-14-4-24-13-32-14-9-34-14-54-18.5-12-3.5-20-9.5-26.5-21.5l2-49c15-12 26-34 26-62 0-40-25-70-58-70z';
/** Each lung: a rounded top by the windpipe, an outer edge that falls to a broad base. */
const LUNG_LEFT =
  'M169 104c-14 0-23 10-29 24-7 17-13 40-15 64-1 14 0 24 6 28 13-1 31-4 47-7 4-31 8-61 11-85 1-13-6-24-20-24z';
const LUNG_RIGHT =
  'M224 104c14 0 23 10 29 24 7 17 13 40 15 64 1 14 0 24-6 28-13-1-31-4-47-7-4-31-8-61-11-85-1-13 6-24 20-24z';
/** The windpipe, with the larynx at its top; it runs down into the heart. */
const AIRWAY =
  'M191.75 138V83c-2.4-1.5-3.75-4.5-3.75-9.5 0-7 3.5-12.5 8.5-12.5s8.5 5.5 8.5 12.5c0 5-1.35 8-3.75 9.5v55z';
/** The glow the windpipe gives off above the larynx, tapering up into the neck. */
const AIRWAY_TIP = 'M192 64c.6-10 2.6-20 4.5-28 1.9 8 3.9 18 4.5 28z';
/** The heart and the vessels around it, between the lungs, with a ragged edge where the airways
 * branch into the lungs. */
const HEART =
  'M196.5 131C199.5 131 203 134 205 138L207.1 139L206 142.3L209.9 145.3L210.1 148L214.9 150.7L213.8 153.4L220.2 157L216.2 160.7L219.4 163.6L218.8 166.6L225 169.8L225.6 173L229.7 175.8L228.7 178.8L231.5 181.7L227.3 184.6L229.9 188.1L226.5 191.8L229.1 195.3L226.9 197.9L230.7 201.1L228.4 204.5L232.9 207.6L232 210.7L236 216C230 224 214 228 196 228C184 228 176 226 172 222L171.2 212L173.8 208.9L172.3 205.8L176.5 202.4L174.9 199.2L179.3 196.6L178.4 193.1L182.2 189.4L179.8 185.9L182.5 183L179.8 180.1L181.8 177.1L179 174.3L180.8 171.1L177 167.9L178.5 164.9L176.4 162L181.4 158.3L181.1 154.7L183.4 152L181 149.3L183.8 146.6L183.9 143.6L188.7 140.3L188 138C190 134 193.5 131 196.5 131Z';
const MOUTH = 'M171-25c8-7 17-9 26-6 9-3 18-1 26 6-7 9-16 14-26 14s-19-5-26-14z';
const LIPS_LINE = 'M174-25c8 3 15 4 23 4s15-1 23-4';
/** The figure, with every organ unlit. */
export function Body() {
  // The skin's gradient is the one drawing that follows the colour scheme: an id of its own per
  // figure, so a light and a dark sheet on one page don't share the first one's.
  const skin = `hs-skin-${useId()}`;
  return (
    <svg className={styles.figure} viewBox={BODY_BOX.join(' ')} aria-hidden="true">
      <defs>
        <linearGradient id={skin} x1="0" y1="0" x2="0" y2="1">
          <stop className={styles.skinTop} offset="0" stopOpacity="0.92" />
          <stop className={styles.skinBottom} offset="1" stopOpacity="0.9" />
        </linearGradient>
      </defs>
      {/* Head, neck, shoulders, arms and chest. */}
      <path fill={`url(#${skin})`} d={TORSO} />
      {/* Where the arms meet the chest. */}
      <path
        className={styles.crease}
        d="M114 200c1 40 2 80 2 130M279 200c-1 40-2 80-2 130"
        fill="none"
        strokeOpacity="0.5"
        strokeWidth="4"
        strokeLinecap="round"
      />
      <path className={styles.organ} d={LUNG_LEFT} />
      <path className={styles.organ} d={LUNG_RIGHT} />
      <path className={styles.airway} d={AIRWAY} />
      <path className={styles.lips} d={MOUTH} />
      <path className={styles.heart} d={HEART} strokeWidth="2" strokeLinejoin="round" />
    </svg>
  );
}

/** The lit lungs and windpipe: glowing orange, the unlit heart over them. */
export function LitLungs() {
  return (
    <svg className={styles.figure} viewBox={boxOf('lungs')} aria-hidden="true">
      <defs>
        <radialGradient id="hs-lung" cx="0.5" cy="0.45" r="0.62">
          <stop offset="0" stopColor="#f8650e" />
          <stop offset="0.7" stopColor="#f05a08" />
          <stop offset="1" stopColor="#cf4706" />
        </radialGradient>
        <radialGradient id="hs-spill">
          <stop offset="0" stopColor="#ff6010" stopOpacity="0.22" />
          <stop offset="0.6" stopColor="#ff5008" stopOpacity="0.08" />
          <stop offset="1" stopColor="#ff5008" stopOpacity="0" />
        </radialGradient>
        <filter id="hs-halo" x="-30%" y="-30%" width="160%" height="160%">
          <feGaussianBlur stdDeviation="5" />
        </filter>
        <linearGradient
          id="hs-airway"
          x1="0"
          y1="40"
          x2="0"
          y2="100"
          gradientUnits="userSpaceOnUse"
        >
          <stop offset="0" stopColor="#f05a08" stopOpacity="0" />
          <stop offset="0.4" stopColor="#f05a08" stopOpacity="0.6" />
          <stop offset="1" stopColor="#f86a12" />
        </linearGradient>
      </defs>
      {/* The light they spill on the body, a soft halo, then the organs over it. */}
      <ellipse cx="196.5" cy="160" rx="86" ry="78" fill="url(#hs-spill)" />
      <g filter="url(#hs-halo)" opacity="0.45">
        <path d={LUNG_LEFT} fill="#ff5a00" />
        <path d={LUNG_RIGHT} fill="#ff5a00" />
        <path d={AIRWAY} fill="url(#hs-airway)" />
      </g>
      <path d={LUNG_LEFT} fill="url(#hs-lung)" />
      <path d={LUNG_RIGHT} fill="url(#hs-lung)" />
      <path d={AIRWAY_TIP} fill="url(#hs-airway)" />
      <path d={AIRWAY} fill="url(#hs-airway)" />
      <path className={styles.heart} d={HEART} strokeWidth="2" strokeLinejoin="round" />
    </svg>
  );
}

/** The lit heart. */
export function LitHeart() {
  return (
    <svg className={styles.figure} viewBox={boxOf('heart')} aria-hidden="true">
      <defs>
        <radialGradient id="hs-heart" cx="0.5" cy="0.55" r="0.6">
          <stop offset="0" stopColor="#f8650e" />
          <stop offset="0.75" stopColor="#f05a08" />
          <stop offset="1" stopColor="#c64406" />
        </radialGradient>
        <filter id="hs-heart-halo" x="-40%" y="-40%" width="180%" height="180%">
          <feGaussianBlur stdDeviation="5" />
        </filter>
        <radialGradient id="hs-heart-spill">
          <stop offset="0" stopColor="#ff6010" stopOpacity="0.26" />
          <stop offset="1" stopColor="#ff5008" stopOpacity="0" />
        </radialGradient>
      </defs>
      <ellipse cx="206" cy="184" rx="42" ry="60" fill="url(#hs-heart-spill)" />
      <path d={HEART} fill="#ff5a00" filter="url(#hs-heart-halo)" opacity="0.5" />
      <path
        d={HEART}
        fill="url(#hs-heart)"
        stroke="#f05a08"
        strokeWidth="2"
        strokeLinejoin="round"
      />
    </svg>
  );
}

/** The lit mouth. */
export function LitMouth() {
  return (
    <svg className={styles.figure} viewBox={boxOf('mouth')} aria-hidden="true">
      <defs>
        <filter id="hs-mouth-halo" x="-40%" y="-80%" width="180%" height="260%">
          <feGaussianBlur stdDeviation="5" />
        </filter>
        <radialGradient id="hs-lips" cx="0.5" cy="0.5" r="0.6">
          <stop offset="0" stopColor="#f8650e" />
          <stop offset="1" stopColor="#d84d08" />
        </radialGradient>
        <radialGradient id="hs-mouth-spill">
          <stop offset="0" stopColor="#ff6010" stopOpacity="0.3" />
          <stop offset="1" stopColor="#ff5008" stopOpacity="0" />
        </radialGradient>
      </defs>
      <ellipse cx="197" cy="-21" rx="41" ry="25" fill="url(#hs-mouth-spill)" />
      <path d={MOUTH} fill="#ff5a00" filter="url(#hs-mouth-halo)" opacity="0.8" />
      <path d={MOUTH} fill="url(#hs-lips)" />
      <path d={LIPS_LINE} fill="none" stroke="#7a2200" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}
```

### health-sheet/icons.tsx

```tsx
/*
 * The phone's small drawings: its status bar, the sheet's close cross and the time card's clock.
 * They never change once drawn.
 */

/** Signal, wireless and battery, as the status bar draws them. */
export function StatusIcons({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 70 13" aria-hidden="true">
      <g fill="currentColor">
        <rect x="0" y="8" width="3" height="4" rx="0.8" />
        <rect x="4.6" y="6" width="3" height="6" rx="0.8" />
        <rect x="9.2" y="3.6" width="3" height="8.4" rx="0.8" />
        <rect x="13.8" y="1" width="3" height="11" rx="0.8" />
        <path d="M30 3.2c2.6 0 5 1 6.8 2.7l1.3-1.3A11.4 11.4 0 0 0 30 1.4a11.4 11.4 0 0 0-8.1 3.2l1.3 1.3A9.6 9.6 0 0 1 30 3.2zm0 3.4c1.6 0 3.1.6 4.3 1.6l1.3-1.3A8 8 0 0 0 30 4.8a8 8 0 0 0-5.6 2.1l1.3 1.3A6.3 6.3 0 0 1 30 6.6zm0 3.4c.7 0 1.3.2 1.8.6L30 12.4l-1.8-1.8c.5-.4 1.1-.6 1.8-.6z" />
        <rect
          x="44.5"
          y="1.5"
          width="21"
          height="10"
          rx="3"
          fill="none"
          stroke="currentColor"
          strokeOpacity="0.4"
        />
        <rect x="46.2" y="3.2" width="17.6" height="6.6" rx="1.6" />
        <path d="M67 5v3.2c.7-.3 1.2-.9 1.2-1.6S67.7 5.3 67 5z" fillOpacity="0.4" />
      </g>
    </svg>
  );
}

export function CloseIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" aria-hidden="true">
      <path
        d="m4 4 12 12M16 4 4 16"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
      />
    </svg>
  );
}

export function ClockIcon({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 20 20" aria-hidden="true">
      <circle cx="10" cy="10" r="7.6" fill="none" stroke="currentColor" strokeWidth="2.1" />
      <path
        d="M10 5.6V10c0 1.4.7 2.5 2 3.4"
        fill="none"
        stroke="currentColor"
        strokeWidth="2.1"
        strokeLinecap="round"
      />
    </svg>
  );
}
```

### health-sheet/use-panel-scroll.ts

```tsx
'use client';

import { useEffect, type RefObject } from 'react';

/*
 * Scrolls the sheet by hand. The sheet's list is overflow: hidden, and this moves its scrollTop:
 * the wheel, a vertical touch drag (which coasts a little when let go), and the browser itself
 * when something inside takes the focus. Why not a native scroll area: Safari on iOS gives each
 * one layers of its own at full device resolution, which here cost more GPU memory than the rest
 * of the phone; a hidden-overflow box is repainted in place instead.
 *
 * At the list's ends the gesture is left to the page, which scrolls on. While the list moves, a
 * thin bar over its right edge shows where it is, then fades.
 */

/** How long the bar stays after the list stops, in ms. */
const LINGER = 700;
/** How far a touch moves before it counts as a drag, in px. */
const SLOP = 6;

type Touch = { x: number; y: number; at: number; vertical: boolean | null; speed: number };

export function usePanelScroll(
  panel: RefObject<HTMLElement | null>,
  bar: RefObject<HTMLElement | null>,
) {
  useEffect(() => {
    const element = panel.current;
    const thumb = bar.current;
    if (!element || !thumb) return;
    let hide = 0;
    let coast = 0;
    let touch: Touch | null = null;
    /** Where the list is, to the fraction of a pixel (its scrollTop is rounded). */
    let position = 0;

    /** Moves the list by `delta` px; false if it is already at that end. */
    const move = (delta: number) => {
      const limit = element.scrollHeight - element.clientHeight;
      const next = Math.min(limit, Math.max(0, position + delta));
      if (next === position) return false;
      position = next;
      element.scrollTop = next;
      return true;
    };

    // The bar: its length is the share of the list in view, its place the share scrolled.
    const show = () => {
      const { scrollTop, scrollHeight, clientHeight } = element;
      if (!touch && !coast) position = scrollTop;
      if (scrollHeight <= clientHeight) return;
      const length = (clientHeight / scrollHeight) * clientHeight;
      const top = (scrollTop / (scrollHeight - clientHeight)) * (clientHeight - length);
      thumb.style.height = `${length}px`;
      thumb.style.transform = `translateY(${top}px)`;
      thumb.dataset.shown = '';
      window.clearTimeout(hide);
      hide = window.setTimeout(() => delete thumb.dataset.shown, LINGER);
    };

    const onWheel = (event: WheelEvent) => {
      if (event.ctrlKey) return;
      position = element.scrollTop;
      // Lines and pages to pixels.
      const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientHeight : 1;
      if (move(event.deltaY * unit)) event.preventDefault();
    };

    // Touch: a vertical drag scrolls the list; a horizontal one is left to the figure's swipe.
    const onTouchStart = (event: TouchEvent) => {
      cancelAnimationFrame(coast);
      coast = 0;
      position = element.scrollTop;
      const point = event.touches[0];
      touch = { x: point.clientX, y: point.clientY, at: event.timeStamp, vertical: null, speed: 0 };
    };
    const onTouchMove = (event: TouchEvent) => {
      if (!touch || event.touches.length > 1) return;
      const point = event.touches[0];
      const dx = point.clientX - touch.x;
      const dy = point.clientY - touch.y;
      if (touch.vertical === null) {
        if (Math.max(Math.abs(dx), Math.abs(dy)) < SLOP) return;
        touch.vertical = Math.abs(dy) > Math.abs(dx);
      }
      if (!touch.vertical) return;
      const elapsed = Math.max(1, event.timeStamp - touch.at);
      touch.speed = 0.8 * (-dy / elapsed) + 0.2 * touch.speed;
      touch.x = point.clientX;
      touch.y = point.clientY;
      touch.at = event.timeStamp;
      if (move(-dy) && event.cancelable) event.preventDefault();
    };
    const onTouchEnd = () => {
      const vertical = touch?.vertical;
      let speed = touch?.speed ?? 0;
      touch = null;
      if (!vertical) return;
      // Let go, it coasts on, slowing down, in px per ms.
      let last = performance.now();
      const step = (now: number) => {
        const elapsed = Math.min(32, Math.max(0, now - last));
        last = now;
        speed *= Math.pow(0.95, elapsed / 16);
        if (Math.abs(speed) < 0.02 || !move(speed * elapsed)) {
          coast = 0;
          return;
        }
        coast = requestAnimationFrame(step);
      };
      coast = requestAnimationFrame(step);
    };

    element.addEventListener('scroll', show, { passive: true });
    element.addEventListener('wheel', onWheel, { passive: false });
    element.addEventListener('touchstart', onTouchStart, { passive: true });
    element.addEventListener('touchmove', onTouchMove, { passive: false });
    element.addEventListener('touchend', onTouchEnd);
    element.addEventListener('touchcancel', onTouchEnd);
    return () => {
      element.removeEventListener('scroll', show);
      element.removeEventListener('wheel', onWheel);
      element.removeEventListener('touchstart', onTouchStart);
      element.removeEventListener('touchmove', onTouchMove);
      element.removeEventListener('touchend', onTouchEnd);
      element.removeEventListener('touchcancel', onTouchEnd);
      window.clearTimeout(hide);
      cancelAnimationFrame(coast);
    };
  }, [panel, bar]);
}
```

### health-sheet/demo-touch.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's finger, and the steps that move it. On a phone a pointer has no arrow: the demo
 * shows a soft disc where a finger would be, faint while it travels and firm while it touches.
 * A demo is a script of awaited steps: wait, travel to a control, tap it, or press and slide (to
 * scroll the sheet or swipe it), calling the component's own handlers as a finger would. Stopping
 * the demo (the reader takes over, or it scrolls away) ends the step in progress at once: its
 * promise rejects, which ends the script right there, and the disc fades out.
 *
 * The disc moves by animations the compositor plays (a slide too: the scrolled value follows that
 * animation's clock), on a box of a fixed size, so it never makes the browser lay anything out.
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Travels to `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Touches and lifts; `action` runs as the finger comes down. */
  tap: (action: () => void) => Promise<void>;
  /**
   * Touches, slides by `by` (in the stage's px) over `ms`, and lifts. `step` gets the share of
   * the way done on every frame, so what is dragged moves with the finger.
   */
  slide: (by: Point, ms: number, step: (share: number) => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** The disc's size, in px; it is placed by its middle. */
const SIZE = 30;

const place = ({ x, y }: Point) => `translate(${x - SIZE / 2}px, ${y - SIZE / 2}px)`;
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const easeInOut = (t: number) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);

/** Plays `script` with the finger over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  finger: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  let frame = 0;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  const disc = finger.firstElementChild as HTMLElement | null;
  // The finger comes in from the lower right of the screen.
  let at: Point = { x: stage.offsetWidth * 0.62, y: stage.offsetHeight * 0.86 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** A point of the screen in the stage's own px (the stage may sit in a scaled container). */
  const toStage = (point: Point): Point => {
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    return { x: (point.x - box.left) / scale, y: (point.y - box.top) / scale };
  };

  /** The disc firms up as it touches (and stays so until it lifts), and fades back as it lifts. */
  const touch = (down: boolean) =>
    disc?.animate(
      down
        ? [
            { opacity: 0.45, transform: 'scale(1)' },
            { opacity: 1, transform: 'scale(0.82)' },
          ]
        : [
            { opacity: 1, transform: 'scale(0.82)' },
            { opacity: 0.45, transform: 'scale(1)' },
          ],
      { duration: down ? 110 : 200, easing: 'ease-out', fill: down ? 'forwards' : 'none' },
    );

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const box = target.getBoundingClientRect();
    const to = toStage({ x: box.left + box.width * share.x, y: box.top + box.height * share.y });
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.12, y: (from.y + to.y) / 2 + dx * 0.12 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    finger.style.transform = place(to);
    flight = finger.animate(keyframes, {
      duration: Math.min(900, Math.max(360, Math.hypot(dx, dy) * 2.2)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const tap: Demo['tap'] = async (action) => {
    const down = touch(true);
    await wait(110);
    action();
    await wait(90);
    down?.cancel();
    const up = touch(false);
    if (up) await step(up.finished);
  };

  const slide: Demo['slide'] = (by, ms, onStep) => {
    const from = at;
    const to = { x: from.x + by.x, y: from.y + by.y };
    at = to;
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 16; i += 1) {
      const share = easeInOut(i / 16);
      keyframes.push({
        transform: place({
          x: from.x + (to.x - from.x) * share,
          y: from.y + (to.y - from.y) * share,
        }),
      });
    }
    flight?.cancel();
    finger.style.transform = place(to);
    const down = touch(true);
    const motion = finger.animate(keyframes, { duration: ms, easing: 'linear' });
    flight = motion;
    return step(
      new Promise<void>((resolve) => {
        const tick = () => {
          const time = motion.currentTime;
          // Clamped at both ends; a finished animation is the end of the way.
          const t =
            motion.playState === 'finished' ? 1 : clamp(typeof time === 'number' ? time / ms : 0);
          onStep(easeInOut(t));
          if (t < 1) frame = requestAnimationFrame(tick);
          else {
            frame = 0;
            down?.cancel();
            touch(false);
            resolve();
          }
        };
        frame = requestAnimationFrame(tick);
      }),
    );
  };

  finger.style.transform = place(at);
  finger.style.opacity = '1';
  script({ wait, moveTo, tap, slide }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    cancelAnimationFrame(frame);
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    disc?.getAnimations().forEach((animation) => animation.cancel());
    finger.style.opacity = '0';
  };
}

/** The finger's box: it moves, on a layer of its own. */
const FINGER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  display: 'block',
  width: SIZE,
  height: SIZE,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  willChange: 'transform',
};

/** Inside it, the disc that firms up on a touch, on a layer of its own. */
const DISC: CSSProperties = {
  display: 'block',
  width: SIZE,
  height: SIZE,
  borderRadius: '50%',
  // Its colours are the stage's tokens: a dark disc on the light sheet, a white one on the dark.
  background: 'radial-gradient(circle, var(--touch), var(--touch-edge) 70%)',
  boxShadow: '0 0 0 1.5px var(--touch-ring), 0 2px 8px var(--touch-shadow)',
  opacity: 0.45,
  transform: 'scale(1)',
  willChange: 'transform, opacity',
};

/** The disc the demo moves around. It is decorative, and invisible while no demo plays. */
export function DemoTouch({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={FINGER} aria-hidden="true">
      <span style={DISC} />
    </span>
  );
}
```

### health-sheet/use-autoplay.ts

```tsx
'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(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```