Health sheet
AdvancedNewA phone sheet that sums up a health finding, with the organ glowing inside a translucent figure and cards that come in one after another.
health-sheet/health-sheet.tsxHealth map
Lungs & Airways
Airway Inflammation
Risk likelihood
Medium Risk
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.
Est. time to risk
<3 months
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.
This summary is general information, not a diagnosis.
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
- 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.
You can add this component with the shadcn CLI or by hand:
Into components/health-sheet of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/health-sheet.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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} />;}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 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.
- 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.
| Prop | Type | Default | Description |
|---|---|---|---|
areas | Area[] | AREAS | The areas in tab order (lungs, heart, mouth), each with its finding: name, risk, time to risk and copy. |
defaultArea | number | 0 | The area shown at first, as its index in areas. |
defaultOpen | boolean | 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 | 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 | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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 (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. *//** 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> = {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. *//* * 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"'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>,'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. */'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;- 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.”