Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUp, Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * How the verdict is composed. Priority is a separate axis — see `level`.
 *
 * - `plain` — the sentence alone, nothing leading it.
 * - `rule` — a hairline rule down the inline start, the quiet default.
 * - `kpi` — the answer is a number: figure first, sentence as its caption.
 * - `icon` — a glyph tile leads the sentence (verified, blocked, refused…).
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/answer-highlight.json

Prompt

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>.

On This Page