All patterns

Accordion Expand

One section opens as the previous one closes, both heights easing over the same beat so the list never jumps.

navigationminimalcalminteraction · finite · starter · ~0.3s
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.

270 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Accordion Expand
 *
 * One section at a time. Opening a row closes whichever row was open,
 * and both heights ease over the same beat so the list resettles instead
 * of jumping.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the list reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `defaultOpenIndex`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type AccordionExpandProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Section open on first render; -1 for all closed. */
  defaultOpenIndex?: number;
  /** Notified with the open index, or -1 when everything is closed. */
  onOpenChange?: (index: number) => void;
};

type VariantConfig = {
  /** Seconds for the height tween. */
  height: number;
  fade: number;
  chevron: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: height is one of the few properties worth animating
// outright — the motion genuinely is a size change — so it runs as a
// short eased tween. A spring on height would overshoot, and a panel
// that springs past its own content is a panel that flashes clipped
// text. The chevron spring stays at/above a 0.8 damping ratio.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and quiet. For long lists where the answer matters more than
  // the opening.
  subtle: {
    height: 0.17,
    fade: 0.1,
    chevron: { type: "spring", stiffness: 610, damping: 46 },
  },
  // Enough time to see the list resettle. All-purpose.
  default: {
    height: 0.26,
    fade: 0.16,
    chevron: { type: "spring", stiffness: 460, damping: 38 },
  },
  // A more deliberate unfold, for a handful of sections on a landing page.
  playful: {
    height: 0.35,
    fade: 0.22,
    chevron: { type: "spring", stiffness: 380, damping: 32 },
  },
};

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 SECTIONS: readonly { question: string; answer: string; meta: string }[] = [
  {
    question: "How do refunds work?",
    answer:
      "Refunds return to the original payment method. Card refunds settle in five to ten business days; balance credits are available immediately.",
    meta: "Billing",
  },
  {
    question: "Can I change the billing cycle?",
    answer:
      "Switch between monthly and annual at any time. The change takes effect on the next renewal and the remaining balance is prorated.",
    meta: "Billing",
  },
  {
    question: "Where do I export account data?",
    answer:
      "Settings, then Data export. Exports are prepared in the background and a download link is emailed to workspace owners.",
    meta: "Account",
  },
  {
    question: "How do I transfer ownership?",
    answer:
      "An owner can hand the workspace to any admin from the Members list. Billing details and integrations move with it.",
    meta: "Account",
  },
];

export default function AccordionExpand({
  variant = "default",
  defaultOpenIndex = 0,
  onOpenChange,
}: AccordionExpandProps) {
  const [open, setOpen] = useState(defaultOpenIndex);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const toggle = (index: number) => {
    const next = open === index ? -1 : index;
    setOpen(next);
    onOpenChange?.(next);
  };

  // Reduced motion: sections still open and close, the height simply
  // arrives at its new value without being animated through.
  const heightTween = reduceMotion
    ? { duration: 0 }
    : { duration: cfg.height, ease: [0.32, 0.72, 0, 1] as const };

  return (
    <div
      style={{
        width: 340,
        borderRadius: 16,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 10px 28px rgba(0,0,0,0.16)",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          padding: "14px 16px 12px",
          borderBottom: `1px solid ${tone(12)}`,
        }}
      >
        <div style={{ fontSize: 14, fontWeight: 650 }}>Help centre</div>
        <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>
          Billing and account questions
        </div>
      </div>

      {SECTIONS.map((section, index) => {
        const isOpen = index === open;
        const panelId = `vibary-accordion-panel-${index}`;
        return (
          <div
            key={section.question}
            style={{
              borderBottom:
                index === SECTIONS.length - 1
                  ? undefined
                  : `1px solid ${tone(10)}`,
            }}
          >
            <button
              type="button"
              onClick={() => toggle(index)}
              aria-expanded={isOpen}
              aria-controls={panelId}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 10,
                width: "100%",
                padding: "13px 16px",
                border: 0,
                background: "transparent",
                color: "inherit",
                fontFamily: "inherit",
                textAlign: "left",
                cursor: "pointer",
              }}
            >
              <span style={{ flex: 1, minWidth: 0 }}>
                {/* Constant weight and size: swapping to a bolder face on
                    open would reflow the line while the panel below is
                    already moving. Emphasis is opacity only. */}
                <motion.span
                  initial={false}
                  animate={{ opacity: isOpen ? 1 : 0.72 }}
                  transition={{ duration: cfg.fade, ease: "easeOut" }}
                  style={{ display: "block", fontSize: 13, fontWeight: 600 }}
                >
                  {section.question}
                </motion.span>
                <span style={{ display: "block", fontSize: 11, opacity: 0.42 }}>
                  {section.meta}
                </span>
              </span>
              <motion.span
                aria-hidden
                initial={false}
                animate={{ rotate: isOpen ? 90 : 0 }}
                transition={reduceMotion ? { duration: 0 } : cfg.chevron}
                style={{
                  display: "grid",
                  placeItems: "center",
                  flexShrink: 0,
                  width: 22,
                  height: 22,
                  borderRadius: 7,
                  background: isOpen ? tone(10) : "transparent",
                  color: isOpen ? ACCENT : "inherit",
                }}
              >
                <svg
                  width="13"
                  height="13"
                  viewBox="0 0 20 20"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M7.5 4.5 13 10l-5.5 5.5" />
                </svg>
              </motion.span>
            </button>

            <AnimatePresence initial={false}>
              {isOpen && (
                <motion.div
                  key="panel"
                  id={panelId}
                  role="region"
                  initial={{ height: 0, opacity: 0 }}
                  animate={{ height: "auto", opacity: 1 }}
                  exit={{
                    height: 0,
                    opacity: 0,
                    transition: {
                      height: heightTween,
                      opacity: { duration: reduceMotion ? 0 : cfg.fade * 0.6 },
                    },
                  }}
                  transition={{
                    height: heightTween,
                    opacity: {
                      duration: reduceMotion ? 0.1 : cfg.fade,
                      ease: "easeOut",
                      delay: reduceMotion ? 0 : cfg.fade * 0.35,
                    },
                  }}
                  // The clip is what keeps the text honest: the answer is
                  // laid out at its final width from frame one and simply
                  // revealed, so no glyph is ever scaled or reflowed by
                  // the container moving around it.
                  style={{ overflow: "hidden" }}
                >
                  <p
                    style={{
                      margin: 0,
                      padding: "0 16px 15px",
                      fontSize: 12.5,
                      lineHeight: 1.6,
                      opacity: 0.7,
                    }}
                  >
                    {section.answer}
                  </p>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        );
      })}
    </div>
  );
}

About this pattern

The workhorse of help centres, settings pages and long forms. Height is one of the few properties worth animating outright, because here the motion genuinely is a size change — but it runs as a short eased tween rather than a spring, since a panel that springs past its own content flashes clipped text at the end of every open. Keeping exactly one section open is what makes the list feel calm: the closing panel and the opening one run over the same duration, so the rows below travel once, in one direction, instead of jumping twice. Inside the panel the answer is laid out at its final width from the first frame and simply revealed by the clip, so no glyph is ever scaled or reflowed by the container moving around it.

Help centre questionsSettings sectionsLong form disclosureProduct specification list

Where it shows up

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

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    Toggle sections unfold in place with the marker rotating.

Related patterns