Source Sheet
A citation opened as a bottom sheet instead of a popover — the passage in the thumb arc, the cited line lifted clear of the sheet, and a way back to where you were reading.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/source-sheet.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SourceSheet" component (lucide-react
icons only; no portal, no animation library) — a citation opened as a bottom
sheet, for a phone.
Why it is not a popover: on a phone there is no hover to ask with, and a card
floated beside the marker covers the very sentence it explains. So the source
arrives from the bottom edge, inside the thumb arc, taking a declared share of
the screen. Everything below follows from the sheet owning the bottom half.
Contract
- Two exports. `SourceSheet = forwardRef<HTMLDivElement, SourceSheetProps>`
wraps the reading pane and owns the sheet; `SourceMark = forwardRef<
HTMLSpanElement, { sourceId: string }>` is the tappable [n] inside the prose.
They talk over one context, so the article is written as ordinary JSX.
- SourceItem = { id: string; title: string; domain?: string; locator?: string;
passage?: string; url?: string; kind?: "web" | "file" | "note";
retrievedAt?: number; unavailable?: boolean; unavailableReason?: string }.
- SourceSheetProps extends the div attributes (children re-declared) and adds
sources: SourceItem[], openId?: string | null, defaultOpenId = null,
onOpenChange?(id: string | null), variant: "passage" | "card" | "rail" =
"passage", height = 0.56 (share of the pane, clamped 0.2..0.85),
restoreOnClose: "auto" | "always" | "never" = "auto", now?: number,
onOpenSource?(source), label = "Article", labels?: Partial<Labels>.
- Controlled and uncontrolled both: `openId` present means controlled, and the
internal state is only written when it is absent. An openId that is not in
`sources` resolves to closed — a stale id must not render an empty panel.
- NUMBERING BELONGS TO THE SET, NOT THE CALLER. A marker's number is its
source's position among the UNIQUE ids of `sources`, first-appearance order,
computed in one useMemo. A source cited three times shows one number three
times, a reference list rendered from the same array agrees for free, and
server and client render the same digits. A marker whose sourceId is not in
the set renders null: a citation to a source you do not have is not a
citation, and must never become a tappable [?].
- `now` is an injected instant, never Date.now() at render. Without it the
"retrieved 4 min ago" line is simply not rendered.
Behavior — reading-position continuity (the actual product)
- Bookmark. The first time the sheet opens, record { scrollTop of the pane,
the marker that was tapped }. This is a one-shot guard held in a ref, read
AND written inside the same handler, so tapping a second citation in the same
sentence extends the trip instead of overwriting where it started. If the
consumer opened the sheet itself (a "show source 2" button, a deep link),
the same capture happens in the reveal effect, which still runs before
anything has scrolled. Drop the bookmark whenever the sheet closes, including
when the consumer closes it by setting openId back to null — otherwise the
next trip restores a position two openings old.
- Reveal. One frame after opening, scroll the pane so the anchored marker sits
about 42% down the strip left ABOVE the sheet (floor 12px). Skip corrections
under 2px. Smooth, or instant under reduced motion.
- The spacer. While the sheet is mounted, append an aria-hidden block as tall
as the sheet (a percentage of the pane) to the end of the article. Without it
a citation in the last paragraph can never be scrolled clear of the sheet,
because the article has already run out of scroll. Tie it to `mounted`, not
to `open`, so it survives the exit animation and the restore that runs with
it.
- Whose scroll is it. `auto` restore has to know whether the reader moved the
article themselves. Do not try to infer it from scroll events (a smooth
programmatic scroll fires dozens of them). Instead: probe pane.scrollTop on
pointerdown and compare on pointerup / pointercancel, and set the flag
directly on wheel and on the scroll keys (arrows, PageUp/Down, Home, End,
Space) — ignoring key events that were already defaultPrevented, since a
marker swallowing Space did not scroll anything.
- Exits, deliberately not the same. Esc, the close button and a swipe down all
mean "I am done here" and follow `restoreOnClose`; the inverted footer button
("Back to reading") always restores. That is the whole point of having it:
after reading around the cited line, the way back is one thumb press.
- Not modal. The article stays readable, scrollable and tappable behind the
sheet, and tapping another marker swaps the sheet's contents instead of
stacking a layer. So: no backdrop, no focus trap, no aria-modal (which would
be a lie about a page you can still read). Tapping the marker of the source
that is already open closes the sheet.
Behavior — gesture and its equals
- Drag lives on the handle + header only, with `touch-action: none` there, so
the sheet's own scrollable body is never fighting the gesture. Pointer
Events only, never separate mouse/touch handlers. Start after 4px, drop the
gesture for good if it is more horizontal than vertical, setPointerCapture on
the node that started it and release it on that same node. Track a smoothed
velocity (0.3 old / 0.7 new sample).
- Release: dismiss on velocity > 0.5px/ms or past 34% of the sheet's own
height; otherwise spring back. Bump a settle counter on every gesture end so
a controlled consumer that IGNORES the close request still gets its sheet
pulled back up instead of being left parked half off the edge.
- Upward overshoot is rubber-banded (x0.3, capped at 6% of the height): the
sheet gives, it never grows.
- Offset is a percentage of the panel's own height driven from state and
written as an inline transform, coalesced through one rAF per frame. Entrance
and spring-back go through a double rAF so the off-screen frame is really
painted; otherwise both style changes coalesce and nothing animates. Under
reduced motion the transition is dropped entirely and the sheet snaps — the
feature survives, the decoration does not.
- Keyboard map, complete: Enter/Space on a marker opens it (preventDefault, or
Space also scrolls the article), Esc closes (preventDefault +
stopPropagation, so only the innermost layer acts on it), ArrowLeft/Right
step between sources, Tab reaches the close button, the open action, the
step pair and the back button. The rail chips are ordinary buttons.
Behavior — states and edges
- No passage: render "No passage was captured for this source." instead of an
empty quote. No usable url and no onOpenSource: no action at all.
- unavailable: no link is offered whatever the url says; the reason is
rendered in its place. Only http/https reach the DOM — `javascript:` and `#`
are treated as no link.
- The step buttons at the ends and any unavailable control use aria-disabled
plus a handler guard, never the disabled attribute: the browser blurs a node
the instant it is disabled and focus falls to <body>.
- One source: no step pair, no counter. Empty `sources`: every marker
disappears and the prose reads clean.
- The panel takes focus once per opening (tabIndex -1, aria-labelledby the
title, aria-describedby the passage) — but NOT when it was already open on
the first frame, which nobody opened. Switching sources does not re-focus,
or the next button would be ripped out from under the finger; a switch is
announced in a polite live region instead, and the first opening is not (the
panel's own title already says it).
- On close, focus goes to the marker the sheet was speaking for — the tapped
one when the position is being restored — with preventScroll, so handing
focus back cannot undo the restore that just started. Never <body>.
- Cleanup: both rAF handles, the unmount timer and the matchMedia subscription
are all released; the reveal rAF is cancelled when the source changes.
Rendering & styling
- Semantic tokens only: bg-background (pane), bg-card / text-card-foreground
(sheet), bg-muted + text-muted-foreground (idle markers, rail chips, meta),
border, ring for focus, bg-muted-foreground/40 for the grab handle. The
highest-priority surfaces INVERT rather than take a colour: the open marker,
the number badge, the current rail chip and the "Back to reading" button are
all bg-foreground / text-background. No hex, rgb or oklch anywhere.
- 16/12/8px radius ladder: rounded-2xl for the frame and the sheet's top,
rounded-lg for inner blocks and buttons, rounded for chips and markers.
- Type stays small and tight: title 14/600, passage 13, meta and chips 11,
numbers tabular-nums.
- The marker is `display: inline` with leading-none, NOT inline-block. An
inline non-replaced box paints its padding without contributing to the line
box, so dropping markers into a paragraph cannot change its line height, and
an inline boundary is not a wrap opportunity, so a marker written flush
against its word cannot orphan onto the next line. A <button> is forced to
inline-block by the UA and loses both guarantees, so the marker is a
`<span role="button" tabIndex={0}>` with an Enter/Space handler. Its 44px
touch target comes from an absolutely positioned ::after (-inset-x-2
-inset-y-3), which grows the hit area without touching layout.
- Every other control is at least 44px in its hit area (size-11 icon buttons,
h-11 chips and actions). Nothing depends on hover.
- The sheet sits on the bottom edge of the screen, so its action row pads with
`max(env(safe-area-inset-bottom), 0.5rem)` — the floor covers devices that
report no inset.
- cn() merges every className; the root spreads the remaining div props.
Customization levers
- variant is the presentation axis and nothing else — the state machine, the
gesture and the ARIA contract are identical across all three. "passage"
quotes first behind a left rule with an attribution line under it; "card"
leads with a metadata grid (kind, host, locator, retrieved) and puts the
passage below; "rail" adds a horizontally scrollable strip of every numbered
source so the whole set can be walked without closing. Adding a fourth is a
new branch in the body plus, at most, one row of chrome.
- height is the single geometry knob: it drives the panel, the article spacer
and the reveal band together, so raising it keeps the cited line visible
automatically. Around 0.4 for a peek, 0.7 for a long passage.
- The reveal band (0.42 of the strip) is where the cited line parks — lower it
toward 0.2 to keep more of the paragraph *after* the citation on screen.
- Marker shape is className-level: the default is a small filled pill carrying
the bare number; brackets, a superscript (add align-super, drop the
background) or a dot are all cosmetic — but keep `display: inline`,
`leading-none` and the ::after hit area, or the line-height guarantee and the
touch target go with them.
- restoreOnClose: "always" for a reference-heavy reader that should never lose
its place, "never" when following a citation is meant to be navigation.
- Sub-blocks are independent: drop the locator, the retrieved line, the number
badge or the step pair without touching the machinery. Add a copy-citation
button, a relevance score or a favicon next to the domain the same way —
anything focusable you add is reachable, because there is no focus trap.
- Gesture feel: DRAG_START_PX (4), CLOSE_RATIO (0.34) and FLING_VELOCITY (0.5)
are the three numbers worth touching. Raise CLOSE_RATIO for a sheet that is
expensive to reopen.
- labels covers every string, including the "Source" word used to build marker
names and the switch announcement, so the component localises without a fork.
- The open action is the consumer's: it renders an <a target="_blank"> for an
http(s) url and fires onOpenSource, or a plain button when there is no url —
route it into your own reader rather than leaving the app.Concepts
- Reading-position bookmark — the pane's scroll offset and the tapped marker, captured once per trip in a ref that is read and written inside the same handler, so a second citation in the same sentence extends the trip rather than restarting it. It is dropped on every close, including a close the consumer performs itself, because a bookmark that outlives its trip restores a position two openings old.
- Cited-line reveal — a sheet that owns the bottom half of the screen will cover the sentence it explains, so opening it scrolls the anchored marker into the strip left above it. The strip, the spacer appended to the article and the parking band are all derived from one
heightprop, which is why raising the sheet cannot push the citation out of sight. - Sheet-height spacer — without extra room at the end of the article, a citation in the last paragraph can never be scrolled clear of the sheet: the scroll has already run out. The spacer is tied to the mount, not to the open state, so it is still there for the exit animation and the restore that runs with it.
- Whose-scroll-is-it attribution —
restoreOnClose: "auto"needs to know if the reader took the position over, and scroll events cannot tell you (a smooth programmatic scroll fires dozens). The answer comes from intent instead: a pointer press that leaves the article somewhere else, a wheel, or a scroll key. - Non-modal sheet — the article stays readable, scrollable and tappable behind the sheet, and another marker swaps its contents instead of stacking a second layer. That rules out a backdrop, a focus trap and
aria-modal, all of which would claim the page behind is unreachable when it is the thing you are reading. - Equal path for every gesture — the swipe down has Esc, a 44px close button and the footer button; stepping between sources has ← and → as well as the step pair and the rail chips; and under reduced motion the transition is dropped while every one of those still works.
Attachment Tray
The composer's + tray: a source panel that opens at the software keyboard's own height and pushes the composer up instead of covering it, driven by drag or button.
Stream Stop Bar
A bottom-edge run bar for a streaming answer — it mints the AbortSignal, stops the run from the thumb arc, climbs above the soft keyboard, and flicks away to uncover the text it covers.