Skip to content
goodtabs UI
BrowseBrowse components

Input with action

An input with a small button inside the field. Enter presses the button.

npx shadcn@latest add https://ui.goodtabs.co/r/input-action.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/input-action

A solid pill inside the field. Enter or the button sends the value.

Pending

Press Enter or the button

The button shows a spinner and disables while the action runs.

Ghost icon action

An icon-only ghost button with an arrow, for a URL or a command.

Usage

import { InputAction } from "@/components/ui/input-action"

<InputAction type="email" placeholder="teammate@company.com" actionLabel="Invite" onAction={invite} pending={isPending} />

Props

PropTypeDefaultDescription
actionLabelstringText on the button. With actionIcon it becomes the accessible name.
onAction(value: string) => voidCalled with the field's current value on click and on Enter.
pendingbooleanfalseShows a spinner and disables the button.
actionVariant"default" | "outline" | "secondary" | "ghost""default"Button variant inside the field.
actionIconReactNodeReplaces the label with an icon; the label becomes the aria-label.
inputClassNamestringClasses for the inner input; className styles the field.

Source

input-action/input-action.tsx
"use client";

import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

type InputActionProps = Omit<React.ComponentProps<typeof Input>, "className"> & {
  /** Text on the button. With actionIcon it becomes the accessible name. */
  actionLabel: string;
  /** Called with the field's current value on click and on Enter. */
  onAction?: (value: string) => void;
  /** Shows a spinner on the button and disables it. */
  pending?: boolean;
  /** Button variant. Default is the solid pill. */
  actionVariant?: "default" | "outline" | "secondary" | "ghost";
  /** Replace the label with an icon. The label becomes the aria-label. */
  actionIcon?: React.ReactNode;
  /** Classes for the outer field. Use inputClassName for the input itself. */
  className?: string;
  /** Classes for the inner input. */
  inputClassName?: string;
};

/**
 * An input with a small button inside the same bordered field.
 * Enter in the input presses the button. The button sits 2px inside the border.
 */
export function InputAction({
  actionLabel,
  onAction,
  pending = false,
  actionVariant = "default",
  actionIcon,
  className,
  inputClassName,
  disabled,
  onKeyDown,
  ...props
}: InputActionProps) {
  const inputRef = React.useRef<HTMLInputElement>(null);

  const fire = () => {
    if (pending || disabled) return;
    onAction?.(inputRef.current?.value ?? "");
  };

  return (
    <div
      data-slot="input-action"
      data-pending={pending || undefined}
      className={cn(
        "flex h-8 w-full min-w-0 items-center rounded-lg border border-input bg-transparent pr-0.5 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-disabled:pointer-events-none has-disabled:opacity-50 dark:bg-input/30",
        className,
      )}
    >
      <Input
        ref={inputRef}
        disabled={disabled}
        aria-busy={pending || undefined}
        onKeyDown={(e) => {
          onKeyDown?.(e);
          if (e.key === "Enter" && !e.defaultPrevented) {
            e.preventDefault();
            fire();
          }
        }}
        className={cn(
          "h-full flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:border-transparent focus-visible:ring-0 disabled:bg-transparent dark:bg-transparent dark:disabled:bg-transparent",
          inputClassName,
        )}
        {...props}
      />
      <Button
        type="button"
        variant={actionVariant}
        size={actionIcon ? "icon-sm" : "sm"}
        disabled={disabled || pending}
        aria-label={actionIcon ? actionLabel : undefined}
        onClick={fire}
        className="shrink-0 rounded-full"
      >
        {pending ? (
          <Loader2Icon
            data-icon={actionIcon ? undefined : "inline-start"}
            aria-hidden="true"
            className="animate-spin motion-reduce:animate-none"
          />
        ) : (
          actionIcon
        )}
        {actionIcon ? null : <span>{actionLabel}</span>}
      </Button>
      <span className="sr-only" aria-live="polite">
        {pending ? "Working" : ""}
      </span>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/input-action/prompt.md, or the Copy AI prompt button above.

When to use

  • One field, one action: invite by email, subscribe, go to a URL, run a command
  • Inline forms in settings and headers where a separate submit button is too heavy
  • When pressing Enter should do the same thing as the button

When not to use

  • Forms with more than one field, use a normal form with a submit button
  • Search as you type, use the search input instead
  • When the action needs confirmation or a second step

Agent notes

  • It takes functions (onAction), so the demo or page that renders it must be a client component
  • The field is uncontrolled by default; every Input prop passes through, so value and onChange control it
  • The button is size sm (28px) inside the 32px field with a 2px inset; set pending from your mutation state
  • Give the input an aria-label or a visible label; the button label does not name the field