All patterns

Product Compare

Adding a product widens a slot while the column's specs ride in from the right.

commerceminimalpremiuminteraction · finite · intermediate · ~0.6s
Interactive · click to play
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.

343 lines · react + motion only
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.

Spec comparison stripPlan or tier pickerShortlist narrowingFeature matrix column add

Where it shows up

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

  • StoreDevicesAudioAccessoriesSupport
    NewAster Studio 14From $1,299
    Finish — Slate
    Buy
    Product page

    Adding a model to the comparison opens a new column beside the existing ones.

Related patterns