All patterns

Suggestion Chips Cascade

Suggested next actions land in quick succession under the answer, one chip after another.

aifriendlyenergeticautomatic · finite · starter · ~0.7s
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.

145 lines · react + motion only
import { motion, useReducedMotion, type Variants } from "motion/react";

/**
 * Vibary · Suggestion Chips Cascade
 *
 * AI-suggested next actions landing in quick succession under a
 * response, one chip after another.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `items`, `onSelect`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SuggestionChipsCascadeProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Chip labels, in the order they should land. */
  items?: string[];
  /** Called with the chosen suggestion. */
  onSelect?: (item: string, index: number) => void;
  /** Accessible name for the group of suggestions. */
  label?: string;
};

type VariantConfig = {
  /** Gap between chips — small enough to read as one cascade, not a queue. */
  stagger: number;
  /** Beat after the answer before the first chip lands. */
  delay: number;
  /** px a chip travels up as it lands. */
  riseY: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8: chips
// are mostly text, and a row of five bouncing labels is the cheapest
// looking thing an AI surface can do. Energy comes from cadence instead.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.07 — no overshoot, minimal travel. For chips that show on every turn.
  subtle: {
    stagger: 0.04,
    delay: 0.06,
    riseY: 5,
    spring: { type: "spring", stiffness: 460, damping: 46 },
  },
  // ζ ≈ 0.93 — lands clean. The all-purpose setting.
  default: {
    stagger: 0.055,
    delay: 0.08,
    riseY: 9,
    spring: { type: "spring", stiffness: 420, damping: 38 },
  },
  // ζ ≈ 0.82 — one soft settle, longer travel, quicker succession.
  playful: {
    stagger: 0.07,
    delay: 0.1,
    riseY: 14,
    spring: { type: "spring", stiffness: 380, damping: 32 },
  },
};

const SAMPLE_ITEMS = [
  "Summarize the thread",
  "Draft a reply",
  "Create a task",
  "Export as PDF",
];

export default function SuggestionChipsCascade({
  variant = "default",
  items = SAMPLE_ITEMS,
  onSelect,
  label = "Suggested actions",
}: SuggestionChipsCascadeProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // Reduced motion: the chips arrive together on one short fade. Order
  // is carried by the DOM, which is what assistive tech reads anyway.
  const listVariants: Variants = reduceMotion
    ? { hidden: {}, shown: {} }
    : {
        hidden: {},
        shown: {
          transition: {
            delayChildren: cfg.delay,
            staggerChildren: cfg.stagger,
          },
        },
      };

  const chipVariants: Variants = reduceMotion
    ? {
        hidden: { opacity: 0 },
        shown: { opacity: 1, transition: { duration: 0.2, ease: "easeOut" } },
      }
    : {
        hidden: { opacity: 0, y: cfg.riseY },
        shown: {
          opacity: 1,
          y: 0,
          transition: {
            y: cfg.spring,
            // Opacity on its own quick curve; springing a fade looks muddy.
            opacity: { duration: 0.18, ease: "easeOut" },
          },
        },
      };

  return (
    <motion.div
      role="group"
      aria-label={label}
      variants={listVariants}
      initial="hidden"
      animate="shown"
      style={{ display: "flex", flexWrap: "wrap", gap: 8 }}
    >
      {items.map((item, index) => (
        <motion.button
          key={item}
          type="button"
          variants={chipVariants}
          // A 1px press instead of a scale: the chip is a text label, and
          // squashing type is the thing this pattern must never do.
          whileTap={reduceMotion ? undefined : { y: 1 }}
          onClick={() => onSelect?.(item, index)}
          style={{
            padding: "7px 12px",
            borderRadius: 999,
            border: "1px solid rgba(127,127,140,0.28)",
            background: "rgba(127,127,140,0.10)",
            color: "inherit",
            font: "inherit",
            fontSize: 13,
            lineHeight: 1.2,
            cursor: "pointer",
          }}
        >
          {item}
        </motion.button>
      ))}
    </motion.div>
  );
}

About this pattern

What the assistant offers to do next: draft a reply, file a ticket, export the summary. The chips arrive in a fast cascade so the eye tracks along the row instead of meeting a wall of buttons at once, and arriving after the answer marks them as an offer rather than a demand. Each chip rises and fades on its own short spring — never a scale, because a chip is mostly a text label and squashed type reads as cheap.

Suggested repliesFollow-up promptsQuick actionsPrompt starters

Where it shows up

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

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    Follow-up prompts presented under a finished response.

Related patterns