Product Compare
Adding a product widens a slot while the column's specs ride in from the right.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Product Compare
*
* Adding a product opens a column: the slot widens while the column's
* contents ride in from the right, spec rows a hair apart. The contents
* are laid out at their final width from the first frame and simply
* clipped by the growing slot, so no label is ever squeezed or
* re-wrapped mid-animation.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color; the product tiles
* are CSS gradients standing in for photography — swap them for an
* <img> and the motion is unchanged.
* Works with zero props; tune via `variant`, `defaultSelected`, `products`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CompareProduct = {
id: string;
name: string;
/** Real product photograph; omit for the gradient stand-in. */
imageSrc?: string;
/** Gradient shown while there is no photo. */
art?: string;
/** One value per spec row, in row order. */
specs: readonly string[];
};
export type ProductCompareSlideProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Products offered by the comparison, in slot order. */
products?: readonly CompareProduct[];
/** How many products are in the comparison on first render. */
defaultSelected?: number;
/** Fires with the number of products being compared. */
onCompareChange?: (count: number) => void;
};
type VariantConfig = {
/** Seconds for the slot to widen — the one genuine size change here. */
width: number;
/** How far the column's contents ride in, in pixels. */
travel: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds between spec rows arriving. */
stagger: number;
};
// Quality rule: width is tweened, never sprung — a column that overshoots
// its own width shoves every neighbour and then takes it back. The
// contents ride in on a spring at or above a 0.8 damping ratio, and the
// figures themselves only translate and fade; nothing about a spec value
// changes size.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Nearly a cut. For comparison strips that get rebuilt constantly.
subtle: {
width: 0.2,
travel: 8,
spring: { type: "spring", stiffness: 560, damping: 42 },
stagger: 0.015,
},
// The slot opens and the column arrives just behind it. All-purpose.
default: {
width: 0.3,
travel: 16,
spring: { type: "spring", stiffness: 420, damping: 35 },
stagger: 0.03,
},
// A longer opening for a dedicated comparison page, where the column
// is the thing you just asked for.
playful: {
width: 0.4,
travel: 26,
spring: { type: "spring", stiffness: 350, damping: 32 },
stagger: 0.045,
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SPEC_LABELS = ["Price", "Weight", "Battery", "Warranty"] as const;
/** The gradients stand in for product photography, so they stay literal —
* they are an image placeholder, not a surface. */
export const DEFAULT_PRODUCTS: readonly CompareProduct[] = [
{
id: "nomad",
name: "Nomad 12",
art: "linear-gradient(150deg, #6E8BFA, #9A6BF0)",
specs: ["$189", "1.4 kg", "18 h", "2 years"],
},
{
id: "ridge",
name: "Ridge Pro",
art: "linear-gradient(150deg, #F0A45C, #D9536B)",
specs: ["$234", "1.1 kg", "22 h", "3 years"],
},
{
id: "field",
name: "Field Lite",
art: "linear-gradient(150deg, #3FB58A, #2E8FA8)",
specs: ["$148", "1.7 kg", "14 h", "1 year"],
},
];
const LABEL_WIDTH = 86;
const COLUMN_WIDTH = 76;
const ROW_HEIGHT = 26;
export default function ProductCompareSlide({
variant = "default",
defaultSelected = 2,
products = DEFAULT_PRODUCTS,
onCompareChange,
}: ProductCompareSlideProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [count, setCount] = useState(
Math.max(1, Math.min(defaultSelected, products.length))
);
const setSelection = (next: number) => {
setCount(next);
onCompareChange?.(next);
};
const shown = products.slice(0, count);
const widthTween = reduceMotion
? { duration: 0 }
: { duration: cfg.width, ease: "easeOut" as const };
return (
<div
style={{
width: 342,
padding: "14px 14px 15px",
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
boxSizing: "border-box",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
marginBottom: 12,
}}
>
<span style={{ fontSize: 13, fontWeight: 650 }}>Side by side</span>
<span style={{ fontSize: 11, opacity: 0.5 }}>
{count} of {products.length} picked
</span>
</div>
<div style={{ display: "flex", overflow: "hidden" }}>
<div style={{ width: LABEL_WIDTH, flexShrink: 0 }}>
<div style={{ height: 62 }} />
{SPEC_LABELS.map((label) => (
<div
key={label}
style={{
height: ROW_HEIGHT,
display: "flex",
alignItems: "center",
fontSize: 11,
opacity: 0.5,
borderTop: `1px solid ${tone(9)}`,
}}
>
{label}
</div>
))}
</div>
{/* Sync mode, deliberately: an exiting column has to collapse its
own width in place so the row closes up, which `popLayout`
would prevent by taking it out of flow. */}
<AnimatePresence initial={false}>
{shown.map((product) => (
<motion.div
key={product.id}
initial={{ width: 0, opacity: 0 }}
animate={{ width: COLUMN_WIDTH, opacity: 1 }}
exit={{
width: 0,
opacity: 0,
transition: {
width: widthTween,
opacity: { duration: reduceMotion ? 0 : 0.14 },
},
}}
transition={{
width: widthTween,
opacity: { duration: reduceMotion ? 0.16 : 0.2, ease: "easeOut" },
}}
// The slot is the only thing that changes size. Everything
// inside is laid out at its final width and clipped, which
// is what keeps the type from being squeezed as it arrives.
style={{ flexShrink: 0, overflow: "hidden" }}
>
<div style={{ width: COLUMN_WIDTH, paddingLeft: 8 }}>
<motion.div
initial={{ x: reduceMotion ? 0 : cfg.travel, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: { ...cfg.spring, delay: cfg.width * 0.4 }
}
style={{ height: 62 }}
>
<div
aria-hidden
style={{
height: 34,
borderRadius: 9,
background: product.imageSrc
? [`url(${product.imageSrc}) center / cover`, product.art]
.filter(Boolean)
.join(", ")
: product.art,
}}
/>
<div
style={{
marginTop: 6,
fontSize: 11.5,
fontWeight: 620,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{product.name}
</div>
</motion.div>
{product.specs.map((value, row) => (
<motion.div
key={SPEC_LABELS[row]}
initial={{ x: reduceMotion ? 0 : cfg.travel, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: {
...cfg.spring,
delay: cfg.width * 0.4 + (row + 1) * cfg.stagger,
}
}
style={{
height: ROW_HEIGHT,
display: "flex",
alignItems: "center",
fontSize: 11.5,
fontWeight: 560,
fontVariantNumeric: "tabular-nums",
borderTop: `1px solid ${tone(9)}`,
}}
>
{value}
</motion.div>
))}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 13 }}>
<button
type="button"
onClick={() => setSelection(Math.min(count + 1, products.length))}
disabled={count >= products.length}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "7px 12px",
fontSize: 12,
fontWeight: 620,
fontFamily: "inherit",
borderRadius: 9,
border: "none",
background: count >= products.length ? tone(10) : ACCENT,
color: count >= products.length ? "inherit" : "#FFFFFF",
opacity: count >= products.length ? 0.55 : 1,
cursor: count >= products.length ? "not-allowed" : "pointer",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
aria-hidden
>
<path d="M10 4.5v11M4.5 10h11" />
</svg>
Add a product
</button>
<button
type="button"
onClick={() => setSelection(Math.max(count - 1, 1))}
disabled={count <= 1}
style={{
padding: "7px 12px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 9,
border: `1px solid ${tone(14)}`,
background: tone(7),
color: "inherit",
opacity: count <= 1 ? 0.45 : 1,
cursor: count <= 1 ? "not-allowed" : "pointer",
}}
>
Remove last
</button>
</div>
</div>
);
}About this pattern
The moment a shopper narrows a shortlist. The slot is the only thing that changes size; the column inside is laid out at its final width from the first frame and simply clipped as the slot opens, so no label is squeezed or re-wrapped while it arrives. Width is tweened rather than sprung — a column that overshoots its own width shoves every neighbour and then takes it back — while the contents ride in on a single settle with the spec rows a hair apart. Removing a product collapses the slot in place so the row closes up.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Product page
Adding a model to the comparison opens a new column beside the existing ones.
