Back to components

Expandable Subscribe

A compact button that expands into an email form, with async submission, loading, confirmation, and error recovery.

formexpand
"use client";

import * as React from "react";
import { motion, useReducedMotion, type Transition } from "motion/react";
import { ArrowUp, LoaderCircle } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group";
import { cn } from "@/lib/utils";
import { CheckMark } from "./check-mark";
import "./expandable-subscribe.css";

export type ExpandableSubscribeProps = Omit<
  React.ComponentProps<"div">,
  "children" | "onSubmit" | "onError" | "defaultValue"
> & {
  /** Resolve after saving the email; reject to show the retry state. */
  onSubscribe: (email: string, signal: AbortSignal) => void | Promise<void>;
  value?: string;
  defaultValue?: string;
  onValueChange?: (email: string) => void;
  /** Called when editing is cancelled by outside press, focus leaving, or Escape. */
  onCancel?: () => void;
  label?: string;
  placeholder?: string;
  inputLabel?: string;
  submitLabel?: string;
  loadingLabel?: string;
  successLabel?: string;
  errorLabel?: string;
  disabled?: boolean;
  /** Expanded width in pixels, capped by the available container width. */
  expandedWidth?: number;
  transition?: Transition;
};

/** A compact email form. Success remains visible until the component is remounted. */
export function ExpandableSubscribe({
  onSubscribe,
  value,
  defaultValue = "",
  onValueChange,
  onCancel,
  label = "Email me",
  placeholder = "Your email",
  inputLabel = "Email address",
  submitLabel = "Subscribe",
  loadingLabel = "Subscribing…",
  successLabel = "You're on the list!",
  errorLabel = "Something went wrong. Please try again.",
  disabled = false,
  expandedWidth = 320,
  transition = { type: "spring", duration: 0.3, bounce: 0 },
  className,
  style,
  ref,
  onBlur,
  ...props
}: ExpandableSubscribeProps) {
  const [state, setState] = React.useState<"closed" | "editing" | "loading" | "success">("closed");
  const [localValue, setLocalValue] = React.useState(defaultValue);
  const [failed, setFailed] = React.useState(false);
  const email = value ?? localValue;
  const reduceMotion = useReducedMotion();
  const inputRef = React.useRef<HTMLInputElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const requestRef = React.useRef<AbortController | null>(null);
  const cancelledRef = React.useRef(false);
  const restoreTriggerFocusRef = React.useRef(false);
  const errorId = React.useId();

  React.useEffect(() => () => requestRef.current?.abort(), []);

  React.useEffect(() => {
    if (state === "editing") {
      cancelledRef.current = false;
      inputRef.current?.focus();
    } else if (state === "closed" && restoreTriggerFocusRef.current) {
      restoreTriggerFocusRef.current = false;
      triggerRef.current?.focus();
    }
  }, [state]);

  const cancel = React.useCallback((restoreFocus = false) => {
    if (state !== "editing" || cancelledRef.current) return;
    // An outside pointer press can also cause blur in the same event cycle.
    cancelledRef.current = true;
    restoreTriggerFocusRef.current = restoreFocus;
    setLocalValue("");
    setFailed(false);
    setState("closed");
    onValueChange?.("");
    onCancel?.();
  }, [state, onValueChange, onCancel]);

  React.useEffect(() => {
    if (state !== "editing") return;
    const document = rootRef.current?.ownerDocument;
    const handleOutsidePress = (event: PointerEvent) => {
      const root = rootRef.current;
      if (root && !event.composedPath().includes(root)) cancel();
    };
    document?.addEventListener("pointerdown", handleOutsidePress, true);
    return () => document?.removeEventListener("pointerdown", handleOutsidePress, true);
  }, [state, cancel]);

  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (disabled || state !== "editing" || requestRef.current) return;
    const controller = new AbortController();
    requestRef.current = controller;
    setFailed(false);
    setState("loading");
    rootRef.current?.focus();
    try {
      await onSubscribe(email.trim(), controller.signal);
      if (!controller.signal.aborted) setState("success");
    } catch {
      if (!controller.signal.aborted) {
        setFailed(true);
        setState("editing");
      }
    } finally {
      if (requestRef.current === controller) requestRef.current = null;
    }
  }

  const editing = state === "editing";
  const expanded = state !== "closed";
  const status = state === "loading" || state === "success";
  const fade = { duration: reduceMotion ? 0 : 0.15 };

  return (
    <div className={cn("relative max-w-full outline-none", className)} style={style} {...props}
      ref={(node) => {
        rootRef.current = node;
        if (typeof ref === "function") return ref(node);
        if (ref) ref.current = node;
      }}
      tabIndex={-1}
      data-slot="expandable-subscribe"
      data-state={state}
      aria-busy={state === "loading"}
      onBlur={(event) => {
        onBlur?.(event);
        if (!event.currentTarget.contains(event.relatedTarget)) cancel();
      }}
    >
      <motion.div
        layout={reduceMotion ? false : true}
        transition={transition}
        className="relative h-12 max-w-full overflow-hidden rounded-xl border border-input bg-secondary text-secondary-foreground"
        style={{ width: expanded ? expandedWidth : "max-content" }}
      >
        <motion.div layout="position" className="h-full" animate={{ opacity: expanded ? 0 : 1 }} transition={fade} inert={expanded}>
          <Button ref={triggerRef} type="button" variant="secondary" disabled={disabled}
            aria-expanded={expanded} onClick={() => setState("editing")}
            className="h-full rounded-xl px-5"
          >{label}</Button>
        </motion.div>

        <motion.form onSubmit={submit}
          className="absolute inset-0"
          animate={{ opacity: editing ? 1 : 0 }} transition={fade} inert={!editing}
          aria-hidden={!editing}
          onKeyDown={(event) => {
            if (event.key === "Escape") {
              event.preventDefault();
              cancel(true);
            }
          }}
        >
          <InputGroup className="h-full rounded-xl border-0">
            <InputGroupInput ref={inputRef} type="email" required autoComplete="email"
              name="email" aria-label={inputLabel} placeholder={placeholder}
              value={email} disabled={disabled || !editing}
              aria-describedby={failed ? errorId : undefined}
              onChange={(event) => {
                setLocalValue(event.target.value);
                onValueChange?.(event.target.value);
                setFailed(false);
              }}
            />
            <InputGroupAddon align="inline-end">
              <InputGroupButton type="submit" variant="secondary" size="icon-sm"
                aria-label={submitLabel} disabled={disabled || !editing}>
                <ArrowUp aria-hidden />
              </InputGroupButton>
            </InputGroupAddon>
          </InputGroup>
        </motion.form>

        <motion.div aria-hidden className="pointer-events-none absolute inset-0 origin-right bg-muted"
          initial={false}
          animate={{ transform: status ? "scaleX(1)" : "scaleX(0)", opacity: status ? 1 : 0 }}
          transition={reduceMotion ? { duration: 0 } : transition}
        />
        <motion.div aria-hidden className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm"
          animate={{ opacity: status ? 1 : 0 }} transition={fade}>
          {state === "loading" ? (
            <LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
          ) : state === "success" ? (
            <div data-slot="expandable-subscribe-confirmation" className="expandable-subscribe-confirmation flex min-w-0 items-center gap-2.5 px-4">
              <CheckMark variant="circle" size="md" strokeWidth={1.5} />
              <span data-slot="expandable-subscribe-success-label" className="expandable-subscribe-success-label truncate">{successLabel}</span>
            </div>
          ) : null}
        </motion.div>
      </motion.div>
      <span role="status" className="sr-only">
        {state === "loading" ? loadingLabel : state === "success" ? successLabel : ""}
      </span>
      {failed && <p id={errorId} role="alert" className="absolute top-full mt-2 w-full text-xs text-destructive">{errorLabel}</p>}
    </div>
  );
}

Installation