All patterns

Semantic Search Rerank

First-pass hits travel to their meaning-ranked places while each relevance bar grows to its score.

aielegantcalmautomatic · finite · intermediate · ~1.2s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

316 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Semantic Search Rerank
 *
 * Keyword hits settling into meaning order: the rows travel to their new
 * places on a shared layout animation, and each relevance bar grows to
 * the score that earned the move.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `hits`, `rerankDelayMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SearchHit = {
  id: string;
  title: string;
  /** Small line under the title — source, date, whatever locates it. */
  meta: string;
  /** 0–1 relevance the reranker settles on. */
  score: number;
};

export type SemanticSearchRerankProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Hits in their first-pass order. Reordered by score when the rerank lands. */
  hits?: SearchHit[];
  /** Beat between the first-pass list and the reorder, in ms. */
  rerankDelayMs?: number;
  /** Accent for the relevance bars. */
  accent?: string;
  /** Fires once the reorder has been applied. */
  onRerank?: () => void;
};

type VariantConfig = {
  /** Spring the rows ride to their new positions. */
  move: { type: "spring"; stiffness: number; damping: number };
  /** How long a relevance bar takes to reach its score. */
  barSeconds: number;
  /** Gap between one bar starting and the next. */
  barStagger: number;
};

// Rows travel, they never scale: the reader is tracking which item moved
// where, and a row that grows on the way loses its identity. Damping
// ratios (ζ = damping / 2√stiffness) stay at or above 0.85, so a list
// that reorders while being read never oscillates under the cursor.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.02 — rows glide to their slots and stop dead. For result panes
  // that rerank on every keystroke.
  subtle: {
    move: { type: "spring", stiffness: 520, damping: 46 },
    barSeconds: 0.4,
    barStagger: 0.03,
  },
  // ζ ≈ 0.92 — one soft settle at the end of the trip. The all-purpose
  // setting.
  default: {
    move: { type: "spring", stiffness: 380, damping: 36 },
    barSeconds: 0.55,
    barStagger: 0.05,
  },
  // ζ ≈ 0.84 — a longer, more visible journey for a results page where the
  // reorder is the headline.
  playful: {
    move: { type: "spring", stiffness: 260, damping: 27 },
    barSeconds: 0.7,
    barStagger: 0.07,
  },
};

const SAMPLE_HITS: SearchHit[] = [
  {
    id: "invoice-export",
    title: "Export invoice history",
    meta: "Billing guide",
    score: 0.41,
  },
  {
    id: "billing-email",
    title: "Change the billing email",
    meta: "Account settings",
    score: 0.36,
  },
  {
    id: "refund-window",
    title: "Refund window on annual plans",
    meta: "Policy · updated Mar 4",
    score: 0.94,
  },
  {
    id: "card-on-file",
    title: "Update the card on file",
    meta: "Payments",
    score: 0.62,
  },
  {
    id: "cancel-midcycle",
    title: "Cancel a plan mid-cycle",
    meta: "Subscriptions",
    score: 0.87,
  },
];

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` gives rows, borders and bar tracks that are correctly
 *  toned in either theme. The relevance accent stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function SemanticSearchRerank({
  variant = "default",
  hits = SAMPLE_HITS,
  rerankDelayMs = 700,
  accent = "#7C7CF0",
  onRerank,
}: SemanticSearchRerankProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [reranked, setReranked] = useState(false);

  // Kept in a ref so an inline arrow from the parent can't restart the
  // timer on every render.
  const onRerankRef = useRef(onRerank);
  useEffect(() => {
    onRerankRef.current = onRerank;
  }, [onRerank]);

  useEffect(() => {
    const id = setTimeout(() => {
      setReranked(true);
      onRerankRef.current?.();
    }, rerankDelayMs);
    return () => clearTimeout(id);
  }, [rerankDelayMs]);

  const ordered = reranked
    ? [...hits].sort((a, b) => b.score - a.score)
    : hits;

  return (
    <div
      style={{
        width: 300,
        display: "flex",
        flexDirection: "column",
        gap: 10,
        fontSize: 13,
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <span style={{ fontSize: 12, opacity: 0.55 }}>
          {hits.length} results
        </span>
        {/* The two orderings share a grid cell, so naming the new order
            cannot nudge the list it describes. */}
        <span
          style={{
            marginLeft: "auto",
            display: "grid",
            justifyItems: "end",
            whiteSpace: "nowrap",
          }}
        >
          <motion.span
            initial={false}
            animate={{ opacity: reranked ? 0 : 0.5 }}
            transition={{ duration: 0.18, ease: "easeOut" }}
            style={{ gridArea: "1 / 1", fontSize: 11.5 }}
          >
            Keyword order
          </motion.span>
          <motion.span
            initial={false}
            animate={{ opacity: reranked ? 1 : 0 }}
            transition={{ duration: 0.18, ease: "easeOut", delay: reranked ? 0.1 : 0 }}
            style={{
              gridArea: "1 / 1",
              display: "inline-flex",
              alignItems: "center",
              gap: 5,
              fontSize: 11.5,
              fontWeight: 600,
              color: accent,
            }}
          >
            <svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
              <path
                d="M6 1.4v9.2M6 1.4 3.2 4.2M6 1.4l2.8 2.8"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
            Ranked by meaning
          </motion.span>
        </span>
      </div>

      <div
        role="list"
        style={{
          display: "flex",
          flexDirection: "column",
          gap: 6,
        }}
      >
        {ordered.map((hit, index) => (
          // layout="position" moves the row without letting the layout
          // animation stretch its box — the title inside keeps a constant
          // size the whole way across.
          <motion.div
            key={hit.id}
            role="listitem"
            layout={reduceMotion ? false : "position"}
            transition={cfg.move}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 10,
              height: 46,
              padding: "0 11px",
              borderRadius: 10,
              background: tone(5),
              border: `1px solid ${tone(10)}`,
            }}
          >
            <span style={{ display: "grid", gap: 2, minWidth: 0, flex: 1 }}>
              <span
                style={{
                  fontSize: 12.5,
                  fontWeight: 600,
                  lineHeight: 1.25,
                  overflow: "hidden",
                  textOverflow: "ellipsis",
                  whiteSpace: "nowrap",
                }}
              >
                {hit.title}
              </span>
              <span style={{ fontSize: 10.5, opacity: 0.5, lineHeight: 1.25 }}>
                {hit.meta}
              </span>
            </span>

            <span
              style={{
                display: "grid",
                gap: 4,
                width: 54,
                justifyItems: "end",
                flexShrink: 0,
              }}
            >
              <motion.span
                initial={false}
                animate={{ opacity: reranked ? 0.75 : 0 }}
                transition={{
                  duration: 0.22,
                  ease: "easeOut",
                  delay: reduceMotion ? 0 : index * cfg.barStagger,
                }}
                style={{
                  fontSize: 10.5,
                  fontWeight: 600,
                  fontVariantNumeric: "tabular-nums",
                }}
              >
                {hit.score.toFixed(2)}
              </motion.span>
              <span
                style={{
                  width: "100%",
                  height: 3,
                  borderRadius: 999,
                  background: tone(12),
                  overflow: "hidden",
                }}
              >
                {/* scaleX rather than width: the bar is a transform, so a
                    reordering list never pays for a layout pass. */}
                <motion.span
                  initial={false}
                  animate={{ scaleX: reranked ? hit.score : 0 }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : {
                          duration: cfg.barSeconds,
                          delay: index * cfg.barStagger,
                          ease: [0.22, 1, 0.36, 1],
                        }
                  }
                  style={{
                    display: "block",
                    height: "100%",
                    borderRadius: 999,
                    background: accent,
                    transformOrigin: "left center",
                  }}
                />
              </span>
            </span>
          </motion.div>
        ))}
      </div>
    </div>
  );
}

About this pattern

Two-stage retrieval made visible: cheap keyword hits land immediately, a reranker scores them a moment later, and the panel reorders itself instead of blinking to a new list. Every item takes a shared layout animation to its new slot, so a reader who was already looking at the third result can follow it up or down rather than having to re-read the page. Positions animate but boxes do not stretch, and the relevance bars grow behind the moves — the number that justified the reorder arrives with it.

Reranked search resultsRetrieval-augmented answersRelevance sortingVector search panel

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Search
    Inbox
    Docs
    Files
    Issues
    SearchNew
    Contract renewalInbox · matched “renewal terms”
    Supplier contract.docxFiles · matched “renewal”
    Q3 planning notesDocs · matched “renewal window”
    RID-412 renewal bannerIssues · matched “renewal”
    Search results

    Result lists that re-sort in place as ranking updates rather than repainting wholesale.

Related patterns