Dice UI
Utilities

Pending

A utility component that disables interactions, maintains keyboard focus, and ensures proper accessibility for buttons, forms, links, switches, and any interactive element while they are pending.

API
"use client";
 
import { Loader2 } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { usePending } from "@/components/components/pending";
 
export function PendingDemo() {
  const [isSubmitting, setIsSubmitting] = React.useState(false);
  const { pendingProps, isPending } = usePending({ isPending: isSubmitting });
 
  const onSubmit = React.useCallback(() => {
    setIsSubmitting(true);
    // Simulate API call
    setTimeout(() => {
      setIsSubmitting(false);
    }, 2000);
  }, []);
 
  return (
    <div className="flex flex-col items-center gap-4">
      <Button onClick={onSubmit} {...pendingProps}>
        {isPending && <Loader2 className="size-4 animate-spin" />}
        {isPending ? "Submitting..." : "Submit"}
      </Button>
 
      <p className="text-muted-foreground text-sm">
        {isPending
          ? "Button is pending - try tabbing to it and pressing Enter"
          : "Click the button to see pending state"}
      </p>
    </div>
  );
}

Installation

CLI

npx shadcn@latest add @diceui/pending

Manual

Copy and paste the following code into your project.

/**
 * Based on React Aria's Button implementation
 * @see https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/Button.tsx
 */
 
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
 
interface UsePendingOptions {
  id?: string;
  isPending?: boolean;
  disabled?: boolean;
}
 
interface UsePendingReturn<T extends HTMLElement = HTMLElement> {
  pendingProps: React.HTMLAttributes<T> & {
    "aria-busy"?: "true";
    "aria-disabled"?: "true";
    "data-pending"?: true;
    "data-disabled"?: true;
  };
  isPending: boolean;
}
 
function usePending<T extends HTMLElement = HTMLElement>(
  options: UsePendingOptions = {},
): UsePendingReturn<T> {
  const { id, isPending = false, disabled = false } = options;
 
  const instanceId = React.useId();
  const pendingId = id || instanceId;
 
  const pendingProps = React.useMemo(() => {
    const props: React.HTMLAttributes<T> & {
      "aria-busy"?: "true";
      "aria-disabled"?: "true";
      "data-pending"?: true;
      "data-disabled"?: true;
    } = {
      id: pendingId,
    };
 
    if (isPending) {
      props["aria-busy"] = "true";
      props["aria-disabled"] = "true";
      props["data-pending"] = true;
 
      function onEventPrevent(event: React.SyntheticEvent) {
        event.preventDefault();
      }
 
      function onKeyEventPrevent(event: React.KeyboardEvent<T>) {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
        }
      }
 
      props.onClick = onEventPrevent;
      props.onPointerDown = onEventPrevent;
      props.onPointerUp = onEventPrevent;
      props.onMouseDown = onEventPrevent;
      props.onMouseUp = onEventPrevent;
      props.onKeyDown = onKeyEventPrevent;
      props.onKeyUp = onKeyEventPrevent;
    }
 
    if (disabled) {
      props["data-disabled"] = true;
    }
 
    return props;
  }, [isPending, disabled, pendingId]);
 
  return React.useMemo(() => {
    return {
      pendingProps,
      isPending,
    };
  }, [pendingProps, isPending]);
}
 
interface PendingProps extends React.ComponentProps<typeof Slot> {
  isPending?: boolean;
  disabled?: boolean;
}
 
function Pending({ id, isPending, disabled, ...props }: PendingProps) {
  const { pendingProps } = usePending({ id, isPending, disabled });
 
  return <Slot {...props} {...pendingProps} />;
}
 
export {
  Pending,
  //
  usePending,
};

Layout

Import the utility and use it with your interactive elements.

import { usePending, Pending } from "@/components/pending";
import { Button } from "@/components/ui/button";

// Using the hook
function SubmitButton() {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const { pendingProps, isPending } = usePending({ isPending: isSubmitting });

  return <Button {...pendingProps}>Submit</Button>;
}

// Using the wrapper component
function SubmitButton() {
  const [isSubmitting, setIsSubmitting] = useState(false);

  return (
    <Pending isPending={isSubmitting}>
      <Button>Submit</Button>
    </Pending>
  );
}

Examples

Wrapper Component

Use the Pending wrapper component to easily apply pending state to any interactive element using Radix Slot.

"use client";
 
import { Loader2 } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Pending } from "@/components/components/pending";
 
export function PendingWrapperDemo() {
  const [isSubmitting, setIsSubmitting] = React.useState(false);
 
  const onSubmit = React.useCallback(() => {
    setIsSubmitting(true);
    // Simulate API call
    setTimeout(() => {
      setIsSubmitting(false);
    }, 2000);
  }, []);
 
  return (
    <div className="flex flex-col items-center gap-4">
      <Pending isPending={isSubmitting}>
        <Button onClick={onSubmit}>
          {isSubmitting && <Loader2 className="size-4 animate-spin" />}
          {isSubmitting ? "Submitting..." : "Submit with Wrapper"}
        </Button>
      </Pending>
 
      <p className="text-muted-foreground text-sm">
        Using the <code className="text-xs">{"<Pending>"}</code> wrapper
        component
      </p>
    </div>
  );
}

Form with Pending State

Handle form submissions with proper pending state management and user feedback.

"use client";
 
import { Loader2 } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { usePending } from "@/components/components/pending";
 
export function PendingFormDemo() {
  const [isSubmitting, setIsSubmitting] = React.useState(false);
  const [submitted, setSubmitted] = React.useState(false);
  const { pendingProps, isPending } = usePending({ isPending: isSubmitting });
 
  const onSubmit = React.useCallback((event: React.FormEvent) => {
    event.preventDefault();
    setIsSubmitting(true);
    setSubmitted(false);
 
    // Simulate API call
    setTimeout(() => {
      setIsSubmitting(false);
      setSubmitted(true);
      setTimeout(() => setSubmitted(false), 2000);
    }, 2000);
  }, []);
 
  return (
    <form onSubmit={onSubmit} className="w-full max-w-sm space-y-4">
      <div className="space-y-2">
        <Label htmlFor="email">Email</Label>
        <Input
          id="email"
          type="email"
          placeholder="[email protected]"
          required
          disabled={isPending}
        />
      </div>
 
      <div className="space-y-2">
        <Label htmlFor="password">Password</Label>
        <Input
          id="password"
          type="password"
          placeholder="••••••••"
          required
          disabled={isPending}
        />
      </div>
 
      <Button type="submit" className="w-full" {...pendingProps}>
        {isPending && <Loader2 className="size-4 animate-spin" />}
        {isPending ? "Signing in..." : "Sign in"}
      </Button>
 
      {submitted && (
        <p className="text-center text-green-600 text-sm dark:text-green-400">
          Successfully signed in!
        </p>
      )}
    </form>
  );
}

Apply pending states to links during async navigation or route transitions.

"use client";
 
import * as React from "react";
import { Pending } from "@/components/components/pending";
 
export function PendingLinkDemo() {
  const [isPending, setIsPending] = React.useState(false);
 
  const onNavigate = React.useCallback(
    (event: React.MouseEvent<HTMLAnchorElement>) => {
      event.preventDefault();
      setIsPending(true);
 
      // Simulate async navigation
      setTimeout(() => {
        setIsPending(false);
      }, 2000);
    },
    [],
  );
 
  return (
    <div className="flex items-center justify-center">
      <Pending isPending={isPending}>
        <a
          href="/docs/components/pending"
          onClick={onNavigate}
          className="text-primary underline-offset-4 hover:underline"
        >
          {isPending ? "Loading dashboard..." : "Go to Dashboard"}
        </a>
      </Pending>
    </div>
  );
}

Toggle Switches

Show pending states on switches that save settings to an API or perform async updates.

"use client";
 
import * as React from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Pending } from "@/components/components/pending";
 
export function PendingSwitchDemo() {
  const [isEnabled, setIsEnabled] = React.useState(false);
  const [isPending, setIsPending] = React.useState(false);
 
  const onToggle = React.useCallback(() => {
    setIsPending(true);
 
    // Simulate async API call to save setting
    setTimeout(() => {
      setIsEnabled((prev) => !prev);
      setIsPending(false);
    }, 1500);
  }, []);
 
  return (
    <div className="flex flex-col gap-6">
      <div className="flex items-center justify-between">
        <div className="flex flex-col gap-1">
          <Label htmlFor="notifications">Email Notifications</Label>
          <p className="text-muted-foreground text-sm">
            {isPending
              ? "Saving..."
              : "Receive email about your account activity"}
          </p>
        </div>
        <Pending isPending={isPending}>
          <Switch
            id="notifications"
            checked={isEnabled}
            onCheckedChange={onToggle}
            className="data-pending:cursor-wait data-pending:opacity-70"
          />
        </Pending>
      </div>
 
      <div className="flex items-center justify-between">
        <div className="flex flex-col gap-1">
          <Label htmlFor="marketing">Marketing Updates</Label>
          <p className="text-muted-foreground text-sm">
            Get tips, updates, and special offers
          </p>
        </div>
        <Switch id="marketing" />
      </div>
    </div>
  );
}

API Reference

usePending

A hook that manages pending state for interactive elements. Returns props to spread on your element and the current pending state.

Prop

Type

Returns

Prop

Type

Pending

A wrapper component that applies pending state behavior to its child using Radix Slot.

Prop

Type

Data AttributeValue
[data-pending]"true"
[data-disabled]"true"

Accessibility

The Pending utility follows best practices for accessible pending states:

  • Uses aria-busy="true" to indicate loading/pending state to screen readers
  • Uses aria-disabled="true" to indicate the element is not currently interactive
  • Maintains focus ability—users can still Tab to the element
  • Prevents all interaction events (click, pointer, keyboard) when pending
  • Screen readers announce the changed button content (e.g., "Submitting...")
  • Provides data-pending attribute for custom styling

Use Cases

The Pending utility works with any interactive element, not just buttons:

  • Buttons - Form submissions, actions
  • Links - Navigation with async route transitions
  • Cards - Clickable cards that load content
  • Menu Items - Actions like export, sync, archive
  • Switches/Toggles - Settings that save to an API
  • Form Fields - Inputs with async validation
  • Tabs - Tab switches that load content
  • Select Options - Dropdowns with async actions
  • Icon Buttons - Icon-only interactive elements
  • List Items - Navigation items in sidebars

Notes

  • Choose your API: Use the usePending hook for more control, or the Pending wrapper for convenience
  • Focus management: Elements remain focusable but don't respond to interactions when pending
  • Event prevention: All pointer and keyboard events are prevented during pending state
  • Styling: Use the data-pending attribute to style elements based on their pending state
  • Accessibility: Screen readers announce both aria-busy (loading state) and aria-disabled (not interactive)
  • Hook usage: When using the hook directly, spread pendingProps last to ensure event prevention works correctly

Credits

On this page