Semantic Search Rerank
First-pass hits travel to their meaning-ranked places while each relevance bar grows to its score.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Search results
Result lists that re-sort in place as ranking updates rather than repainting wholesale.
Related patterns
- Sentiment Tint ShiftEach row's edge and wash ease from neutral toward the tone the classifier detected.
- AI Result RevealThe result card rises into place while its confidence value counts up to the final number.
- Agent Step TimelineEach finished stage of an agent run ticks over and grows its connector toward the one after it.