shadimage
snapcn
componentanswer-highlightsource: ok · hit

Answer Highlight

AI answer highlight animation, ChatGPT-style chat response with a text selection. A question in a pill, an answer that writes itself a word at a time, and then the beat the whole shot is for: a caret lands mid-sentence and a selection band drags across one statement, wrapping the line the way a real one does — and settles onto the single word that matters. Name the sentence and the word as strings; the run is found in the answer for you, punctuation and all.

npx shadcn add "https://snapcn.dev/r/answer-highlight.json"
free

Where it goes

statschat

Interaction

animated-counterdragdropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
remotion@remotion/google-fonts

Registry dependencies

https://snapcn.dev/r/snap-cn-ui.json

Files1 · source cached 10/9/2026, 2:23:26 AM

registry/snap-cn/answer-highlight/index.tsx
// Answer Highlight · snapcn — https://snapcn.dev/docs/ai-input/answer-highlight
"use client";

import { loadFont as loadSans } from "@remotion/google-fonts/Inter";
import {
  AbsoluteFill,
  Easing,
  interpolate,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import {
  mixOklch,
  resolveFont,
  type SnapCnTheme,
  useSnapCnTheme,
  withAlpha,
} from "@/lib/snap-cn-ui";

// Loaded through @remotion/google-fonts, never a CSS variable — a Remotion
// bundle has none of the app's CSS, so a `var(--font-…)` gets you the right face
// in the Player and a fallback in the mp4 (design-system rule 4).
const { fontFamily: SANS } = loadSans("normal", {
  weights: ["400", "500"],
  subsets: ["latin"],
});

// ---------------------------------------------------------------------------
// Proportions
//
// Fractions of the composition height, never pixels, so the scene holds its
// shape at 720p, 1080p or a vertical crop.
//
// The question pill's numbers are the same ones `agent-steps` carries, measured
// off the same 718x398 recording by the mass of the pill's fill and by
// cross-correlating its column ink under a scale sweep. They are repeated here
// rather than imported because a registry file is copied whole into somebody's
// project — a shared constants module would be a dependency they did not ask
// for. If you change one, change the other.
// ---------------------------------------------------------------------------

/** Question pill height. 22.0px of 398. */
const PILL_H = 0.0553;
/** Corner radius as a fraction of pill height. Not a stadium — that would be 0.5. */
const PILL_R = 0.28;
/** Pill padding either side of the label, as a fraction of pill height. */
const PILL_PAD = 0.42;
/** Question type size, as a fraction of composition height. */
const QUESTION_FONT = 0.0223;
/** Answer type size. Bigger than the question: the question is a caption on the
 *  thing you are here to read. */
const ANSWER_FONT = 0.0385;
/**
 * Answer line height, as a multiple of its own type size.
 *
 * Looser than a paragraph would normally want. The selection band fills its
 * line box the way a browser's does, so at 1.5 the band on one line sits
 * against the type on the next and the whole thing reads as a solid block of
 * colour instead of as two selected lines.
 */
const ANSWER_LEADING = 1.64;
/** Answer measure, as a fraction of frame width. Long enough for three or four
 *  lines of a real answer, short enough that the eye does not lose the line. */
const MEASURE = 0.6;
/** Gap between the pill's bottom and the answer's first baseline box. */
const PILL_GAP = 0.075;

/** Selection band padding above and below the type, as a fraction of the answer
 *  type size — a browser's own selection sits a little proud of the leading. */
const BAND_PAD_Y = 0.18;
/** Band corner radius, as a fraction of the answer type size. */
const BAND_R = 0.1;
/** The caret at the leading edge of the drag, as a fraction of the type size. */
const CARET_W = 0.06;

// ---------------------------------------------------------------------------
// Motion
//
// The pill's entrance is measured (see `agent-steps`). Everything below it is
// *chosen*, not fitted — there is no reference recording for this scene — so the
// numbers say what they are for rather than quoting an rms.
// ---------------------------------------------------------------------------

/** The question's entrance: scale, blur and opacity, in seconds. */
const INTRO_S = 0.375;
const INTRO_BLUR_S = 0.2;
const INTRO_SCALE = 1.31;
/** Blur at t=0, as a fraction of composition height. */
const INTRO_BLUR = 0.0141;

/**
 * One word of the answer's arrival, in seconds, and the gap between two of them.
 *
 * The stagger is the read: at 0.055s a 30-word answer takes 1.65s, which is fast
 * enough to feel generated and slow enough that the eye can start reading before
 * it finishes. Each word takes longer to arrive (0.22s) than the gap between two
 * — so four or five words are always mid-arrival, which is what stops it looking
 * like a ticker.
 */
const WORD_IN_S = 0.22;
const WORD_STEP_S = 0.055;
/** How far a word rises as it arrives, as a fraction of its own type size. */
const WORD_RISE = 0.34;

/**
 * Seconds the drag spends on each word of the statement.
 *
 * A selection is not a fade. The band is painted as a gradient with a hard stop
 * inside the word the drag is currently crossing, so the leading edge moves
 * *through* a word rather than jumping over it — at 30fps a per-word step would
 * advance the edge three whole words a second and read as four chips appearing,
 * not as a hand pulling across the line.
 *
 * 0.11s is a real drag: four words in a little under half a second.
 */
const BAND_STEP_S = 0.11;

/** The word inside the statement, once the statement is selected. */
const WORD_BAND_S = 0.2;
/** Word gap, as an em of the answer type. Padding, not margin — see the span. */
const WORD_GAP = 0.28;

// ---------------------------------------------------------------------------

export interface AnswerHighlightProps {
  /** The prompt, in the pill at the top. */
  question?: string;
  /** The answer. It writes itself a word at a time; line breaks are the
   *  browser's, so write a paragraph and let `measure` set the rag. */
  answer?: string;
  /**
   * The run inside `answer` that gets selected — word for word, as written.
   * A run that is not found simply never highlights, which is the signal that
   * the two strings have drifted apart.
   */
  statement?: string;
  /** The one word inside `statement` that takes the second, stronger band. */
  word?: string;
  /** Seconds the question holds alone before the answer starts. */
  questionHold?: number;
  /** Seconds between the answer finishing and the drag starting. */
  beforeDrag?: number;
  /** Seconds the selected statement holds before the word is picked out. */
  beforeWord?: number;
  /** Seconds per word of the answer's arrival, and of the drag. */
  wordStep?: number;
  dragStep?: number;
  /** Page behind the scene. Defaults to `theme.background`. */
  paperColor?: string;
  /** Pill fill. Defaults to `theme.card`. */
  pillColor?: string;
  /** Question ink. Defaults to `theme.mutedForeground`. */
  questionColor?: string;
  /** Answer ink. Defaults to `theme.foreground`. */
  answerColor?: string;
  /** The selection, the caret and the picked word. Defaults to `theme.primary`. */
  accentColor?: string;
  /** Where the lockup sits, as a fraction of frame height. */
  centerY?: number;
  fontFamily?: string;
  theme?: Partial<SnapCnTheme>;
  mode?: "light" | "dark";
  speed?: number;
}

// --- Pure helpers (unit-tested) -------------------------------------------

/**
 * The answer, as the tokens the scene actually animates.
 *
 * Split on whitespace and *keep the punctuation attached to its word*, because
 * the selection band is painted per token: a comma that is its own token gets
 * its own band and its own arrival, and the band comes apart at every clause.
 */
export function splitWords(text: string): string[] {
  return text.split(/\s+/).filter(Boolean);
}

/**
 * A token with its outer punctuation taken off, for matching only.
 *
 * So that `statement="keep the timeline declarative"` finds the answer's
 * `declarative,` — writing the comma into the prop to make the match land is
 * the kind of thing nobody remembers and everybody debugs twice.
 */
export function bare(wordToken: string): string {
  return wordToken.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
}

/**
 * Where `phrase` sits in `words`, as a half-open [start, end) word range.
 *
 * Matched on the joined text rather than word by word so that a phrase written
 * with its own internal spacing still lands. Returns null when it is not there —
 * the caller draws no band, which makes a drifted prop visible instead of
 * highlighting some near-miss run of words.
 */
export function findRun(
  words: string[],
  phrase: string,
): [number, number] | null {
  const target = splitWords(phrase).map(bare);
  if (target.length === 0) return null;
  const wanted = target.join(" ");
  const haystack = words.map(bare);
  for (let i = 0; i + target.length <= words.length; i += 1) {
    if (haystack.slice(i, i + target.length).join(" ") === wanted) {
      return [i, i + target.length];
    }
  }
  return null;
}

/**
 * The selection band on one word, as a CSS background.
 *
 * `across` is how far the drag has crossed this word: nothing at 0, a solid
 * band at 1, and in between a hard-edged gradient with the caret sitting just
 * past the stop. Returned as `undefined` when there is nothing to paint so the
 * span keeps its cheap "no background" path.
 */
export function paintBand(
  across: number,
  fill: string,
  caret: string,
  caretWidth: number,
): string | undefined {
  if (across <= 0) return undefined;
  if (across >= 1) return fill;
  const stop = `${(across * 100).toFixed(2)}%`;
  return (
    `linear-gradient(to right, ${fill} 0 ${stop}, ` +
    `${caret} ${stop}, ${caret} calc(${stop} + ${caretWidth.toFixed(2)}px), ` +
    `transparent calc(${stop} + ${caretWidth.toFixed(2)}px))`
  );
}

/** Progress of a value that starts at `at` and takes `dur` frames. */
export function ramp(fc: number, at: number, dur: number): number {
  if (dur <= 0) return fc >= at ? 1 : 0;
  return Math.max(0, Math.min(1, (fc - at) / dur));
}

/**
 * The frame the drag's leading edge is on, as a fractional word index.
 *
 * Fractional on purpose: the caret is drawn on `floor(edge)` and the band on
 * everything behind it, so the edge can sit between two words and the bar does
 * not jump a whole word at a time on a 30fps clock.
 */
export function dragEdge(
  fc: number,
  at: number,
  stepF: number,
  count: number,
): number {
  if (stepF <= 0) return fc >= at ? count : 0;
  return Math.max(0, Math.min(count, (fc - at) / stepF));
}

// --- Defaults --------------------------------------------------------------

const DEFAULT_ANSWER =
  "Build one composition per scene and keep the timeline declarative, " +
  "so the same props always render the same frames. Anything that reads " +
  "the clock at render time is a bug you will find in the export.";

// ---------------------------------------------------------------------------

export function AnswerHighlight({
  question = "How should we structure the demo video?",
  answer = DEFAULT_ANSWER,
  statement = "keep the timeline declarative",
  word = "declarative",
  questionHold = 0.6,
  beforeDrag = 0.45,
  beforeWord = 0.6,
  wordStep = WORD_STEP_S,
  dragStep = BAND_STEP_S,
  paperColor,
  pillColor,
  questionColor,
  answerColor,
  accentColor,
  centerY = 0.5,
  fontFamily,
  theme: themeOverride,
  mode,
  speed = 1,
}: AnswerHighlightProps) {
  const frame = useCurrentFrame();
  const { fps, height, width } = useVideoConfig();
  const t = useSnapCnTheme(themeOverride, mode);
  const face = resolveFont(fontFamily ?? t.fontFamily) ?? SANS;

  const fc = frame * speed;
  const paper = paperColor ?? t.background;
  const pill = pillColor ?? t.card;
  const askInk = questionColor ?? t.mutedForeground;
  const ink = answerColor ?? t.foreground;
  const accent = accentColor ?? t.primary;

  // A selection is a *tint of the page*, never the accent at full strength.
  // Primary behind body type at any real opacity puts white-on-blue in the
  // middle of a paragraph and the line stops being readable — which is exactly
  // why a browser's own selection is pale until the window loses focus.
  //
  // Alpha, not `mixOklch`. Mixing a warm off-white (#faf9f6) toward a blue in
  // oklch takes the short way round the hue wheel and passes through green: the
  // first cut of this scene highlighted the answer in pale mint. A tint of a
  // colour over paper is an alpha composite, not a hue interpolation.
  const band = withAlpha(accent, 0.16);

  const font = ANSWER_FONT * height;
  const words = splitWords(answer);
  const run = findRun(words, statement);
  const wordRun = run ? findRun(words, word) : null;
  // Only a word *inside* the selected statement is picked out. One that happens
  // to appear earlier in the answer is not the one meant.
  const picked =
    wordRun && run && wordRun[0] >= run[0] && wordRun[1] <= run[1]
      ? wordRun
      : null;

  // --- the clock -----------------------------------------------------------

  const answerAt = questionHold * fps;
  const stepF = wordStep * fps;
  const answerDone = answerAt + (words.length - 1) * stepF + WORD_IN_S * fps;
  const dragAt = answerDone + beforeDrag * fps;
  const dragStepF = dragStep * fps;
  const runLen = run ? run[1] - run[0] : 0;
  const dragDone = dragAt + runLen * dragStepF;
  const wordAt = dragDone + beforeWord * fps;

  const edge = run ? dragEdge(fc, dragAt, dragStepF, runLen) : 0;
  const wordIn = Easing.out(Easing.cubic)(ramp(fc, wordAt, WORD_BAND_S * fps));

  const intro = Easing.out(Easing.cubic)(ramp(fc, 0, INTRO_S * fps));
  const introBlur =
    INTRO_BLUR *
    height *
    (1 - Easing.out(Easing.cubic)(ramp(fc, 0, INTRO_BLUR_S * fps)));

  return (
    <AbsoluteFill
      style={{
        backgroundColor: paper,
        alignItems: "center",
        justifyContent: "center",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          position: "absolute",
          top: `${centerY * 100}%`,
          transform: "translateY(-50%)",
          width: MEASURE * width,
          display: "flex",
          flexDirection: "column",
          alignItems: "flex-start",
        }}
      >
        {/* The question. Same pill, same entrance, as `agent-steps`. */}
        <div
          style={{
            opacity: intro,
            filter: introBlur > 0.01 ? `blur(${introBlur}px)` : undefined,
            transform: `scale(${interpolate(intro, [0, 1], [INTRO_SCALE, 1])})`,
            transformOrigin: "0% 50%",
            marginBottom: PILL_GAP * height,
          }}
        >
          <div
            style={{
              fontFamily: face,
              fontSize: QUESTION_FONT * height,
              fontWeight: 500,
              lineHeight: 1,
              color: askInk,
              background: pill,
              height: PILL_H * height,
              borderRadius: PILL_R * PILL_H * height,
              padding: `0 ${PILL_PAD * PILL_H * height}px`,
              display: "flex",
              alignItems: "center",
              whiteSpace: "nowrap",
            }}
          >
            {question}
          </div>
        </div>

        {/* The answer. Every word is its own inline span, which is what lets the
            selection band be a background rather than a measured rectangle: the
            browser wraps the spans, and a band that is painted per word wraps
            with them and breaks square at the line end, exactly like a real one.
            No DOM measurement, and it survives any copy you put in it. */}
        <div
          style={{
            fontFamily: face,
            fontSize: font,
            fontWeight: 400,
            lineHeight: ANSWER_LEADING,
            color: ink,
          }}
        >
          {words.map((w, i) => {
            const arrive = Easing.out(Easing.cubic)(
              ramp(fc, answerAt + i * stepF, WORD_IN_S * fps),
            );
            const inRun = run ? i >= run[0] && i < run[1] : false;
            const isPicked = picked ? i >= picked[0] && i < picked[1] : false;
            // How far the drag has crossed *this* word: 0 not yet, 1 fully past,
            // and everything between is where the leading edge actually is.
            const across =
              inRun && run ? Math.max(0, Math.min(1, edge - (i - run[0]))) : 0;
            const fill = isPicked
              ? withAlpha(accent, 0.16 + 0.14 * wordIn)
              : band;

            return (
              <span
                // biome-ignore lint/suspicious/noArrayIndexKey: the answer is a fixed ordered token list, so the index is the identity
                key={i}
                style={{
                  display: "inline-block",
                  opacity: arrive,
                  transform: `translateY(${(1 - arrive) * WORD_RISE * font}px)`,
                  color: isPicked ? mixOklch(ink, accent, wordIn) : ink,
                  // One background does the band, its leading edge and the
                  // caret: solid behind everything the drag has passed, a hard
                  // stop where it has got to, and a sliver of the accent just
                  // ahead of that stop while it is still moving. A caret drawn
                  // as a separate element would have to be positioned, and
                  // positioning it means measuring the text.
                  background: paintBand(across, fill, accent, CARET_W * font),
                  // The word gap is padding, not margin, so the band is drawn
                  // behind the space as well: a selection over four words comes
                  // out as one unbroken run rather than four chips with holes
                  // where the spaces were.
                  padding: `${BAND_PAD_Y * font}px ${WORD_GAP}em ${BAND_PAD_Y * font}px 0`,
                  borderTopLeftRadius:
                    run && i === run[0] ? BAND_R * font : undefined,
                  borderBottomLeftRadius:
                    run && i === run[0] ? BAND_R * font : undefined,
                  borderTopRightRadius:
                    run && i === run[1] - 1 ? BAND_R * font : undefined,
                  borderBottomRightRadius:
                    run && i === run[1] - 1 ? BAND_R * font : undefined,
                }}
              >
                {w}
              </span>
            );
          })}
        </div>
      </div>
    </AbsoluteFill>
  );
}

More from snapcn

Browse all components in this library →