Answer Highlight
The conclusion of an AI answer stated first — four monochrome priority levels across plain, rule, KPI and icon compositions, plus a rail that pins the verdict below the notch once the reader has thumbed past the card.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/answer-highlight.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AnswerHighlight" component using
lucide-react icons and a cn() class merger (no other runtime dependencies).
It states the conclusion of an AI answer first, at the head of that answer, on
a phone — and keeps the conclusion on screen after the reader scrolls past it.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>;
remaining props spread on the root, className merged with cn().
- verdict: string (required). A string, not a node, on purpose: it is also the
accessible name of the pinned rail and the line that has to survive being
truncated there.
- eyebrow?: string = "Conclusion" — the small label above the verdict.
- variant?: "plain" | "rule" | "kpi" | "icon" = "rule" — the composition.
- level?: 1 | 2 | 3 | 4 = 2 — the priority rung. A separate axis from variant:
every composition can be shown at every rung.
- value?: string, unit?: string — kpi only, already formatted for the reader's
locale. icon?: ReactNode — icon only, defaults to a check glyph.
- meta?: string — one line of provenance ("4 sources · 12 min ago"), formatted
by the caller from an injected instant. The component never reads the clock.
- detail?: ReactNode + detailLabel?: string = "Why" — the supporting note
disclosed by tapping the card. Omit detail and no disclosure renders at all.
- expanded? / defaultExpanded? = false / onExpandedChange? — controlled and
uncontrolled both supported; controlled when expanded !== undefined.
- pin?: boolean = true, pinLabel?: string = "back to the conclusion".
- children?: ReactNode — the answer this conclusion heads, rendered untouched
under the card. It is inside the component for one structural reason: a
sticky element can only stick inside its own parent, so the rail's range has
to be the answer it belongs to.
Behavior
- Disclosure: when detail is present the entire header becomes one full-width
<button type="button"> with min-h-11 (≥44px), aria-expanded and
aria-controls pointing at the panel id. Not a 24px chevron — on a phone the
target is the whole 390px header. The chevron is decorative and aria-hidden.
Keyboard map is the native one: Tab reaches the button, Enter and Space
toggle it. There is no gesture anywhere in this component and therefore no
threshold, no pointer capture and no touch-action to negotiate.
- The panel stays mounted and is hidden with the `hidden` attribute rather than
animated to 0 height: content collapsed with overflow stays in the tab order,
and a reader would otherwise land inside an invisible panel.
- Focus on collapse: an effect watches expanded going true → false and, only if
document.activeElement is null or <body> (i.e. the browser dumped focus when
the panel disappeared), focuses the trigger with preventScroll. Guarding on
<body> is what stops a parent-driven collapse from yanking focus off whatever
the reader is using elsewhere on the page.
- The rail: an IntersectionObserver watches the card with threshold 0 and root
= the nearest scrollable ancestor (walk parentElement, take the first whose
computed overflow-y is auto/scroll/overlay; null = the viewport). Pin only
when !entry.isIntersecting AND entry.boundingClientRect.bottom <=
entry.rootBounds.top — "not intersecting" alone is also true while the card
is still below the fold, which would arm the rail before the reader ever saw
the card. Disconnect the observer on unmount and whenever pinning is turned
off. Skip the observer entirely when IntersectionObserver is undefined; the
component then simply never pins.
- The rail is a zero-height `sticky top-0` layer holding one absolutely
positioned bar, so arming it shifts nothing in the flow. Its sticky range is
the component root, which means it retires when the whole answer has been
scrolled past instead of following the reader into the next answer.
- The rail is a real <button>: aria-label is `${verdict} — ${pinLabel}` and the
visible line is aria-hidden, so the accessible name is the whole sentence
even though the visible text is truncated to one line. Activating it moves
focus first and synchronously (to the disclosure trigger, or to the card,
which carries tabIndex={-1} as a deliberate successor) and only then calls
scrollIntoView — the rail unmounts the moment the card is back in view, and
focus must never be handed to <body>.
- Auto-disable: with no children there is nothing to scroll past, so the rail
would arm at the exact moment its sticky range ended. Treat pin as false
unless children exist.
- prefers-reduced-motion is read through useSyncExternalStore over
matchMedia (subscribed, never read during render, so SSR is stable and the
listener is removed on unmount). It only downgrades decoration: the rail's
entrance animation and the chevron rotation are dropped, and scrollIntoView
falls back from "smooth" to "auto". The rail still arms, still returns.
- Edge cases: variant="kpi" with no value yet falls back to the plain
composition instead of rendering an empty slab; the verdict is never clamped
in the card (a truncated conclusion is not a conclusion) and truncates only
in the rail; meta truncates to one line; an absent detail renders no chevron.
Rendering & styling
- Semantic tokens only, zero colour literals. Hierarchy is monochrome and the
top rung INVERTS rather than taking a colour:
level 1 = bg-foreground text-background (secondary text text-background/65,
dividers border-background/20, focus ring ring-background);
level 2 = border bg-card; level 3 = bg-muted; level 4 = border bg-transparent.
The same ladder drives the rule colour (bg-background/70 → bg-foreground →
bg-muted-foreground/50 → bg-border), the icon tile and the verdict's own
weight (15px/600 → 14px/600 → 13px/500 → 13px/400).
- Surfaces follow a 16/12/8 radius ladder: card rounded-2xl border bg-card p-4,
rail rounded-xl, icon tile rounded-lg. Type is small and tight: eyebrow 10px
uppercase with 0.08em tracking, verdict 13–15px, meta 10px, detail 12px.
The kpi figure is 30px, font-semibold, tabular-nums, tracking-tight.
- Safe area: the rail is offset by
calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 0.5rem) so
it clears the notch instead of hiding under it, and the same expression is
the card's scroll-margin-top so a return trip lands below the notch. Reading
the CSS variable first lets a device-frame preview simulate the inset on a
laptop; without viewport-fit=cover the whole expression is a plain 8px.
- The header is built from <span>s, never <p>/<div>: it sits inside a <button>
whenever detail is present, and a button may only contain phrasing content.
Customization levers
- Add a rung, or re-rank the ladder: the seven Record<level, string> tables
(card / quiet / verdict / rule / tile / divider / body) are the entire visual
definition — edit one row and nothing else moves.
- Add a composition: one more branch beside kpi and icon, reusing the shared
eyebrow + sentence column. Nothing in the rail or the observer knows the
variant.
- Rail geometry: change the 0.5rem in the offset for a tighter or looser gap
under the notch; give the observer a negative top rootMargin to arm the rail
a few pixels before the card fully leaves; drop backdrop-blur if the app
never scrolls content under it.
- Density: px-4 py-3 on the header and 12px detail copy suit a 390px screen;
px-5 py-4 with 13px copy reads better on a tablet.
- Turn features off cleanly: pin={false} for a card that never follows the
reader, no detail for a card with no disclosure, level={4} for an aside.
- Colour: deliberately absent. If a project genuinely needs a destructive
verdict, add a fifth table keyed by tone rather than overloading level —
priority and severity are different questions.Concepts
- Conclusion first — the verdict is authored as one sentence and rendered before the answer that supports it, so the first screenful on a phone carries the finding rather than the preamble leading up to it.
- Priority ladder in monochrome — four rungs built from surface, type weight and size; the top rung inverts (
bg-foreground text-background) instead of taking a colour, which keeps the hierarchy intact in a re-themed project and in dark mode without a second table. - Pinned verdict rail — once the card leaves through the top of its scroll box, the conclusion condenses into a single line pinned below
env(safe-area-inset-top); it is a button, not a decoration, and tapping it returns to the card. - Sticky range is the answer — the rail lives inside the component, so it can only stick for as long as the answer it belongs to is on screen; scroll past the whole answer and it retires instead of trailing the reader into the next one.
- Truncate only in the rail — the card wraps the whole verdict because a cut-off conclusion is not a conclusion; only the one-line rail shortens it, and the untruncated sentence remains its accessible name.
- Focus handed to a successor — every path that unmounts or hides something (collapsing the panel, dismissing the rail by returning to the card) names the element that takes focus next, so it is never dropped on
<body>.
Answer Metric Card
An in-chat metric card for a phone — headline, delta chip and a thumb-scrubbed sparkline, bar row or intensity strip whose readout is hoisted into the header, out from under the finger.
Criteria Checklist
A live rule-satisfaction panel for a field being typed on a phone: a row per rule, a two-column grid, or a one-line summary of the next thing to fix — chosen from the keyboard height the visual viewport actually reports.