All patterns

Form Reset Clear

A wash crosses each row in turn and the value is dropped while the row is covered.

formscalmminimalinteraction · finite · intermediate · ~0.7s
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.

331 lines · react + motion only
import { useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Form Reset Clear
 *
 * Clearing a form as a legible pass rather than an instant blank. A wash
 * crosses each row in turn, the value is dropped at the moment the row
 * is covered, and the empty field is revealed as the wash leaves — so
 * every field is seen being emptied instead of the whole form vanishing
 * between frames.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, and the wash sits on the CSS
 * system page colour, so it hides a value correctly in either theme.
 * Works with zero props; tune via `variant`, `fields`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ResettableField = {
  /** Field label. */
  label: string;
  /** The value the form starts with, and returns to on restore. */
  value: string;
  /** Shown once the field is empty. */
  placeholder: string;
  /** Native input type. */
  type?: string;
};

export type FormResetClearProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Fields in the order the pass runs through them. */
  fields?: ResettableField[];
  /** Label on the clearing control. */
  clearLabel?: string;
  /** Label once the form is empty. */
  restoreLabel?: string;
  /** Accent for the line that travels with the wash. */
  accent?: string;
  /** Fires once every field has been emptied. */
  onCleared?: () => void;
};

type VariantConfig = {
  /** ms between one row starting its wash and the next. This is the
   *  wave: too small and the form simply blanks. */
  stepMs: number;
  /** Seconds one row's wash takes from arriving to gone. */
  washSeconds: number;
  /** Where in the wash the value is dropped, 0–1. */
  dropAt: number;
};

// Quality rule: nothing springs, nothing shakes and nothing scales that
// carries a letter. The wash is an opacity change and the line that
// travels with it is a single scaleX — a form clearing itself should
// look deliberate, which is the opposite of a shudder.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A brisk pass. For a long form someone resets often.
  subtle: { stepMs: 45, washSeconds: 0.24, dropAt: 0.4 },
  // Each row is seen being emptied. The all-purpose setting.
  default: { stepMs: 80, washSeconds: 0.34, dropAt: 0.4 },
  // A slower, more deliberate sweep down the form.
  playful: { stepMs: 130, washSeconds: 0.48, dropAt: 0.4 },
};

/** 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
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_FIELDS: ResettableField[] = [
  {
    label: "Full name",
    value: "Rowan Ellis",
    placeholder: "Who is requesting this?",
    type: "text",
  },
  {
    label: "Work email",
    value: "rowan@meridianlabs.com",
    placeholder: "name@company.com",
    type: "email",
  },
  {
    label: "Company",
    value: "Meridian Labs",
    placeholder: "Company name",
    type: "text",
  },
  {
    label: "Role",
    value: "Operations lead",
    placeholder: "What is your role?",
    type: "text",
  },
  {
    label: "Reference",
    value: "PO-40912",
    placeholder: "Optional",
    type: "text",
  },
];

export default function FormResetClear({
  variant = "default",
  fields = DEFAULT_FIELDS,
  clearLabel = "Clear all",
  restoreLabel = "Restore values",
  accent = "#5B5BD6",
  onCleared,
}: FormResetClearProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [values, setValues] = useState(() => fields.map((field) => field.value));
  const [pass, setPass] = useState(0);
  const [clearing, setClearing] = useState(false);
  const [ring, setRing] = useState(false);
  const timers = useRef<number[]>([]);
  // Scoped per instance: hard-coded ids would tie every label to the
  // first form on the page the moment there were two of these.
  const formId = useId();

  // Every scheduled step is tracked, so unmounting mid-pass leaves
  // nothing running.
  useEffect(() => {
    const scheduled = timers.current;
    return () => {
      for (const id of scheduled) window.clearTimeout(id);
    };
  }, []);

  const empty = values.every((value) => value.trim() === "");

  const restore = () => {
    setValues(fields.map((field) => field.value));
  };

  const clear = () => {
    if (clearing) return;
    // Reduced motion: the reset still happens, it just happens at once.
    // The information is the empty form; the pass is presentation.
    if (reduceMotion) {
      setValues(fields.map(() => ""));
      onCleared?.();
      return;
    }
    setClearing(true);
    setPass((count) => count + 1);
    fields.forEach((_, index) => {
      const id = window.setTimeout(
        () =>
          setValues((current) =>
            current.map((value, position) => (position === index ? "" : value))
          ),
        index * cfg.stepMs + cfg.washSeconds * cfg.dropAt * 1000
      );
      timers.current.push(id);
    });
    const done = window.setTimeout(
      () => {
        setClearing(false);
        onCleared?.();
      },
      (fields.length - 1) * cfg.stepMs + cfg.washSeconds * 1000
    );
    timers.current.push(done);
  };

  return (
    <div style={{ width: 300, color: "inherit" }}>
      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        {fields.map((field, index) => (
          <div key={field.label}>
            <label
              htmlFor={`${formId}-${index}`}
              style={{
                display: "block",
                marginBottom: 4,
                fontSize: 10.5,
                fontWeight: 650,
                letterSpacing: 0.2,
                opacity: 0.5,
              }}
            >
              {field.label}
            </label>

            <div style={{ position: "relative" }}>
              <input
                id={`${formId}-${index}`}
                type={field.type ?? "text"}
                value={values[index]}
                placeholder={field.placeholder}
                onChange={(event) => {
                  const next = event.target.value;
                  setValues((current) =>
                    current.map((value, position) =>
                      position === index ? next : value
                    )
                  );
                }}
                style={{
                  width: "100%",
                  boxSizing: "border-box",
                  padding: "8px 11px",
                  fontSize: 12.5,
                  fontFamily: "inherit",
                  color: "inherit",
                  background: tone(6),
                  border: `1px solid ${tone(13)}`,
                  borderRadius: 9,
                  outline: "none",
                }}
              />

              {/*
                The wash is what makes the reset legible: it covers the
                row, the value is dropped underneath it, and the empty
                field is revealed as it leaves. It has to be page-opaque
                to hide anything, so it sits on the CSS system colour and
                carries the field's own resting fill on top of it.
              */}
              {clearing && (
                <motion.span
                  key={`wash-${pass}-${index}`}
                  aria-hidden
                  initial={{ opacity: 0 }}
                  animate={{ opacity: [0, 1, 1, 0] }}
                  transition={{
                    duration: cfg.washSeconds,
                    delay: (index * cfg.stepMs) / 1000,
                    times: [0, cfg.dropAt * 0.7, cfg.dropAt, 1],
                    ease: "easeOut",
                  }}
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 9,
                    background: "Canvas",
                    color: "CanvasText",
                    overflow: "hidden",
                    pointerEvents: "none",
                  }}
                >
                  <span
                    style={{
                      position: "absolute",
                      inset: 0,
                      borderRadius: 9,
                      background: tone(6),
                      border: `1px solid ${tone(13)}`,
                      boxSizing: "border-box",
                    }}
                  />
                  {/* One line travelling the way the wave is going, so
                      the pass has a direction rather than a flicker. */}
                  <motion.span
                    initial={{ scaleX: 0 }}
                    animate={{ scaleX: 1 }}
                    transition={{
                      duration: cfg.washSeconds,
                      delay: (index * cfg.stepMs) / 1000,
                      ease: "easeOut",
                    }}
                    style={{
                      position: "absolute",
                      left: 1,
                      right: 1,
                      bottom: 1,
                      height: 1.5,
                      borderRadius: 1,
                      background: accent,
                      transformOrigin: "left",
                    }}
                  />
                </motion.span>
              )}
            </div>
          </div>
        ))}
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 10,
          marginTop: 13,
        }}
      >
        <button
          type="button"
          onClick={empty && !clearing ? restore : clear}
          disabled={clearing}
          onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
          onBlur={() => setRing(false)}
          style={{
            padding: "7px 13px",
            fontFamily: "inherit",
            fontSize: 12,
            fontWeight: 650,
            color: "inherit",
            opacity: clearing ? 0.5 : 1,
            background: tone(7),
            border: `1px solid ${tone(14)}`,
            borderRadius: 9,
            cursor: clearing ? "default" : "pointer",
            boxShadow: ring ? `0 0 0 3px ${tone(20)}` : "none",
            outline: "none",
          }}
        >
          {empty && !clearing ? restoreLabel : clearLabel}
        </button>

        <span role="status" style={{ fontSize: 11, opacity: 0.45 }}>
          {clearing
            ? "Clearing the form"
            : empty
              ? "Form is empty"
              : `${values.filter((value) => value.trim() !== "").length} fields filled`}
        </span>
      </div>
    </div>
  );
}

About this pattern

Emptying a form so the reset can be watched instead of guessed at. A wash arrives over the first row, the value underneath is dropped at the moment the row is fully covered, and the empty field with its placeholder is revealed as the wash leaves — then the next row, and the next. A single line travels with each wash so the pass has a direction rather than a flicker. The wash has to hide a value to work, so it sits on the CSS system page colour and carries the field's own resting fill on top. Nothing shakes, nothing springs.

Clear a filled formReset filtersStart a request overEmpty a draft before reuse

Where it shows up

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

  • Ridgeline
    Members
    General
    Billing
    Security
    Integrations
    MembersNew
    Nils Bergströmnils@ridgeline.coAdmin
    Priya Ramanpriya@ridgeline.coMember
    Marcus Bellmarcus@ridgeline.coMember
    Dana Whitfielddana@ridgeline.coViewer
    Data table

    Clearing a filter set empties the conditions visibly rather than instantly.

Related patterns