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
| Prop | Type | Default | Description |
|---|---|---|---|
| actionLabel | string | Text on the button. With actionIcon it becomes the accessible name. | |
| onAction | (value: string) => void | Called with the field's current value on click and on Enter. | |
| pending | boolean | false | Shows a spinner and disables the button. |
| actionVariant | "default" | "outline" | "secondary" | "ghost" | "default" | Button variant inside the field. |
| actionIcon | ReactNode | Replaces the label with an icon; the label becomes the aria-label. | |
| inputClassName | string | Classes 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