Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ChevronLeft,
  ChevronRight,
  CornerUpLeft,
  ExternalLink,
  FileText,
  Globe,
  Lock,
  type LucideIcon,
  StickyNote,
  X,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/source-sheet.json

Prompt

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 height prop, 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 attributionrestoreOnClose: "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.

On This Page