All patterns

Conditional Section Reveal

Picking an option unfolds the extra fields it requires, the section easing its height open.

formscalmminimalinteraction · 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.

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

/**
 * Vibary · Conditional Section Reveal
 *
 * Choosing an option unfolds the fields that option requires. The section
 * eases its height open and the fields inside arrive just behind the
 * edge, so the form grows instead of jumping.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are mixed
 * from the inherited text color, so the form reads correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `accent`, `defaultChoice`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FormSectionRevealProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Which option starts selected. */
  defaultChoice?: "personal" | "business";
  /** Accent for the selected option and field focus. */
  accent?: string;
  /** Overall width. */
  width?: number | string;
  /** Fires when the selected option changes. */
  onChoiceChange?: (choice: "personal" | "business") => void;
};

type VariantConfig = {
  /** Seconds for the height change. */
  height: number;
  /** Seconds for a field to fade up. */
  field: number;
  /** Seconds between one field and the next. */
  step: number;
  /** How far a field rises as it arrives, in px. */
  rise: number;
};

// Quality rule: height runs as a short eased tween, never a spring — a
// panel that springs past its own height flashes clipped text on the way
// back. The fields inside ride a stagger small enough to read as one
// movement, and they move without changing size, so no glyph is ever
// scaled by the container opening around it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick and matter-of-fact. For long forms where extra fields are
  // routine rather than an event.
  subtle: { height: 0.19, field: 0.13, step: 0.02, rise: 3 },
  // Enough time to see the section arrive. The all-purpose setting.
  default: { height: 0.26, field: 0.18, step: 0.05, rise: 6 },
  // A more deliberate unfold for a short, high-intent form.
  playful: { height: 0.34, field: 0.23, step: 0.08, rise: 9 },
};

/** 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 CHOICES = [
  { value: "personal", label: "Personal", hint: "Billed to you" },
  { value: "business", label: "Business", hint: "Billed to a company" },
] as const;

const EXTRA_FIELDS = [
  { name: "Legal company name", placeholder: "Meridian Labs Ltd", type: "text" },
  { name: "VAT number", placeholder: "GB 123 4567 89", type: "text" },
] as const;

export default function FormSectionReveal({
  variant = "default",
  defaultChoice = "personal",
  accent = "#5B5BD6",
  width = 320,
  onChoiceChange,
}: FormSectionRevealProps) {
  const [choice, setChoice] = useState<"personal" | "business">(defaultChoice);
  const [focusedField, setFocusedField] = useState<string | null>(null);
  const [focusedChoice, setFocusedChoice] = useState<string | null>(null);
  const groupName = useId();
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const select = (next: "personal" | "business") => {
    setChoice(next);
    onChoiceChange?.(next);
  };

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

  return (
    <div style={{ width, color: "inherit" }}>
      <fieldset
        style={{
          margin: 0,
          padding: 0,
          border: 0,
          display: "flex",
          flexDirection: "column",
          gap: 8,
        }}
      >
        <legend
          style={{
            padding: 0,
            fontSize: 11.5,
            fontWeight: 650,
            letterSpacing: 0.3,
            opacity: 0.55,
          }}
        >
          ACCOUNT TYPE
        </legend>

        <div style={{ display: "flex", gap: 8 }}>
          {CHOICES.map((option) => {
            const selected = choice === option.value;
            const showFocus = focusedChoice === option.value;
            return (
              <label
                key={option.value}
                style={{
                  position: "relative",
                  flex: 1,
                  display: "flex",
                  alignItems: "center",
                  gap: 9,
                  padding: "10px 11px",
                  borderRadius: 11,
                  border: `1px solid ${selected ? accent : tone(13)}`,
                  background: selected ? tone(8) : tone(4),
                  boxShadow: showFocus
                    ? `0 0 0 3px color-mix(in srgb, ${accent} 26%, transparent)`
                    : "0 0 0 0 transparent",
                  cursor: "pointer",
                  // State colors settle on a CSS transition so the
                  // animation loop stays height/transform/opacity only.
                  transition:
                    "border-color 150ms ease-out, background-color 150ms ease-out, box-shadow 150ms ease-out",
                }}
              >
                {/* The real radio stays in the DOM and keeps every native
                    behaviour — arrow keys move between options, the label
                    is clickable, the focus ring is painted on the card. */}
                <input
                  type="radio"
                  name={groupName}
                  value={option.value}
                  checked={selected}
                  onChange={() => select(option.value)}
                  onFocus={() => setFocusedChoice(option.value)}
                  onBlur={() => setFocusedChoice(null)}
                  style={{
                    position: "absolute",
                    width: 1,
                    height: 1,
                    margin: 0,
                    padding: 0,
                    opacity: 0,
                    pointerEvents: "none",
                  }}
                />
                <span
                  aria-hidden
                  style={{
                    display: "grid",
                    placeItems: "center",
                    width: 15,
                    height: 15,
                    flex: "0 0 auto",
                    borderRadius: "50%",
                    border: `1.5px solid ${selected ? accent : tone(28)}`,
                    transition: "border-color 150ms ease-out",
                  }}
                >
                  <motion.span
                    initial={false}
                    animate={{ scale: selected ? 1 : 0 }}
                    transition={
                      reduceMotion
                        ? { duration: 0 }
                        : { type: "spring", stiffness: 560, damping: 46 }
                    }
                    style={{
                      width: 7,
                      height: 7,
                      borderRadius: "50%",
                      background: accent,
                    }}
                  />
                </span>
                <span style={{ minWidth: 0 }}>
                  <span
                    style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}
                  >
                    {option.label}
                  </span>
                  <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
                    {option.hint}
                  </span>
                </span>
              </label>
            );
          })}
        </div>
      </fieldset>

      <AnimatePresence initial={false}>
        {choice === "business" && (
          <motion.div
            key="business-section"
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{
              height: 0,
              opacity: 0,
              transition: {
                height: heightTween,
                opacity: { duration: reduceMotion ? 0 : cfg.field * 0.6 },
              },
            }}
            transition={{
              height: heightTween,
              opacity: { duration: reduceMotion ? 0 : cfg.field, ease: "easeOut" },
            }}
            // The clip is what keeps the fields honest: they are laid out
            // at full width from the first frame and simply revealed, so
            // nothing inside is ever squeezed by the container.
            style={{ overflow: "hidden" }}
          >
            <div
              style={{
                display: "flex",
                flexDirection: "column",
                gap: 12,
                paddingTop: 14,
              }}
            >
              {EXTRA_FIELDS.map((field, index) => (
                <motion.div
                  key={field.name}
                  initial={
                    reduceMotion
                      ? { opacity: 0 }
                      : { opacity: 0, y: cfg.rise }
                  }
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: reduceMotion ? 0.1 : cfg.field,
                    ease: "easeOut",
                    delay: reduceMotion ? 0 : cfg.height * 0.4 + index * cfg.step,
                  }}
                >
                  <label
                    style={{
                      display: "block",
                      marginBottom: 5,
                      fontSize: 11.5,
                      fontWeight: 600,
                      opacity: 0.6,
                    }}
                  >
                    {field.name}
                    <input
                      type={field.type}
                      placeholder={field.placeholder}
                      onFocus={() => setFocusedField(field.name)}
                      onBlur={() => setFocusedField(null)}
                      style={{
                        display: "block",
                        width: "100%",
                        marginTop: 5,
                        padding: "9px 11px",
                        boxSizing: "border-box",
                        borderRadius: 10,
                        border: `1px solid ${
                          focusedField === field.name ? accent : tone(13)
                        }`,
                        background: tone(5),
                        color: "inherit",
                        fontFamily: "inherit",
                        fontSize: 13,
                        fontWeight: 500,
                        outline: "none",
                        boxShadow:
                          focusedField === field.name
                            ? `0 0 0 3px color-mix(in srgb, ${accent} 26%, transparent)`
                            : "0 0 0 0 transparent",
                        transition:
                          "border-color 150ms ease-out, box-shadow 150ms ease-out",
                      }}
                    />
                  </label>
                </motion.div>
              ))}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

Forms that ask everything up front feel twice as long as they are, so the fields only one kind of customer needs stay out of sight until that customer is identified. The section eases its height open on a short curve — never a spring, because a panel that overshoots its own height flashes clipped text on the way back — and the fields inside rise into place just behind the growing edge, staggered barely enough to read as one movement. Choosing the other option folds the section away and the form closes the gap rather than leaving a hole where it was.

Checkout billing detailsConditional questionnaireSettings that depend on a choiceAccount setup questions

Where it shows up

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

  • Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    Business billing adds company and tax fields without a page change.

Related patterns