Agents (llms.txt)
Octocat

Input

A simple text input component.

import { Input } from "@/components/input";

export default function InputExample() {
  return (
    <div className="w-90">
      <Input placeholder="Type something..." />
    </div>
  );
}

Dependencies

Source Code

import type { VariantProps } from "cva";
import { createContext, use, useRef } from "react";
import { Slot } from "@/components/slot";
import { composeRefs } from "@/lib/compose-refs";
import { cn, cva } from "@/lib/utils/classnames";

const inputFrameBase = [
  "w-full",
  "font-medium [&,&_*]:placeholder:text-foreground-secondary",
  "border outline-none",
  "transition",
  "flex items-center",
  "[:focus-visible,:has(:focus-visible)]:ring-(length:--ring-width) ring-ring [:focus-visible,:has(:focus-visible)]:border-accent [:focus-visible,:has(:focus-visible)]:text-foreground",
  "[:disabled,:has(input:disabled)]:cursor-not-allowed [:disabled,:has(input:disabled)]:opacity-50",
  "[[data-invalid],:has([data-invalid])]:border-error! [[data-invalid],:has([data-invalid])]:ring-error/20 [[data-invalid],:has([data-invalid])]:hover:border-error",
];

const inputFrameVariants = {
  default: [
    "border-border shadow-xs hover:border-[color-mix(in_oklab,var(--color-border),var(--color-foreground)_8%)]",
  ],
  minimal: [
    "border-transparent bg-transparent hover:bg-background-secondary [:focus-visible,:has(:focus-visible)]:bg-background",
  ],
};

const inputFrameSize = {
  xs: "h-6 rounded-lg text-sm",
  sm: "h-8 rounded-lg text-sm",
  md: "h-10 py-(--inset) rounded-xl text-base",
  lg: "h-12 py-(--inset) rounded-2xl text-base",
};

const inputElementStyles = [
  "file:mr-3 file:inline-flex file:h-full file:items-center file:cursor-pointer file:border-0 file:bg-transparent file:p-0 file:font-medium file:text-foreground",
  "[&::-webkit-calendar-picker-indicator]:hidden",
  "[&[type=date],&[type=time],&[type=datetime-local],&[type=month],&[type=week],&[type=file]]:py-0",
];

const inputHorizontalPaddingBySize = {
  xs: "gap-1 px-2",
  sm: "gap-1 px-3",
  md: "gap-2 px-4",
  lg: "gap-2 px-5",
};

const inputStyle = cva({
  base: [...inputFrameBase, ...inputElementStyles],
  variants: {
    variant: inputFrameVariants,
    size: {
      xs: cn(inputFrameSize.xs, inputHorizontalPaddingBySize.xs),
      sm: cn(inputFrameSize.sm, inputHorizontalPaddingBySize.sm),
      md: cn(inputFrameSize.md, inputHorizontalPaddingBySize.md),
      lg: cn(inputFrameSize.lg, inputHorizontalPaddingBySize.lg),
    },
  },
  defaultVariants: {
    variant: "default",
    size: "md",
  },
});

const inputGroupStyle = cva({
  base: inputFrameBase,
  variants: {
    variant: inputFrameVariants,
    size: inputFrameSize,
  },
  defaultVariants: {
    variant: "default",
    size: "md",
  },
});

const inputInGroupStyle = cva({
  base: ["h-full w-full min-w-8 cursor-[inherit] outline-none", ...inputElementStyles],
  variants: {
    size: {
      xs: "px-2 [&:has(+[data-input-addon])]:pr-1 [[data-input-addon]+&]:pl-1",
      sm: "px-3 [&:has(+[data-input-addon])]:pr-2 [[data-input-addon]+&]:pl-2",
      md: "px-4 [&:has(+[data-input-addon])]:pr-3 [[data-input-addon]+&]:pl-3",
      lg: "px-5 [&:has(+[data-input-addon])]:pr-4 [[data-input-addon]+&]:pl-4",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

const inputAddonStyle = cva({
  base: ["flex h-full shrink-0 items-center justify-center"],
  variants: {
    size: {
      xs: "first:pl-2 last:pr-2",
      sm: "first:pl-3 last:pr-3",
      md: "first:pl-4 last:pr-4",
      lg: "first:pl-5 last:pr-5",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

export type InputSize = "xs" | "sm" | "md" | "lg";
export type InputVariant = "default" | "minimal";

interface InputGroupContextValue {
  size: InputSize;
  variant: InputVariant;
}

const InputGroupContext = createContext<InputGroupContextValue | null>(null);

const useInputStyle = (props: VariantProps<typeof inputStyle> & { className?: string }) => {
  const ctx = use(InputGroupContext);
  if (ctx) {
    return inputInGroupStyle({ size: ctx.size });
  }
  return inputStyle(props);
};

interface InputProps
  extends Omit<React.ComponentPropsWithRef<"input">, "size">,
    VariantProps<typeof inputStyle> {
  invalid?: boolean;
}

const Input = ({ className, invalid, size, variant, ...props }: InputProps) => {
  return (
    <input
      {...(invalid && { "data-invalid": true, "aria-invalid": true })}
      className={cn(useInputStyle({ variant, size }), className)}
      {...props}
    />
  );
};

interface InputGroupProps
  extends React.ComponentPropsWithRef<"div">,
    VariantProps<typeof inputGroupStyle> {}

const InputGroup = ({
  className,
  size = "md",
  variant = "default",
  children,
  onClick,
  ...props
}: InputGroupProps) => {
  const handleGroupClick = (e: React.MouseEvent<HTMLDivElement>) => {
    onClick?.(e);

    // If the click is on the group container (not on an input or addon), focus the first input child.
    if (!e.defaultPrevented && e.target === e.currentTarget) {
      const firstInput = e.currentTarget.querySelector("input");
      firstInput?.focus();
    }
  };

  return (
    <InputGroupContext value={{ size: size ?? "md", variant: variant ?? "default" }}>
      {/** biome-ignore lint/a11y/useKeyWithClickEvents: intentional */}
      {/** biome-ignore lint/a11y/noStaticElementInteractions: intentional */}
      <div
        data-ui-input-group
        className={inputGroupStyle({ variant, size, className })}
        onClick={handleGroupClick}
        {...props}
      >
        {children}
      </div>
    </InputGroupContext>
  );
};

interface InputAddonProps extends React.ComponentPropsWithRef<"div"> {
  asChild?: boolean;
}

const InputAddon = ({ ref, className, asChild, onClick, ...props }: InputAddonProps) => {
  const ctx = use(InputGroupContext);
  const Comp = asChild ? Slot : "div";
  const internalRef = useRef<HTMLDivElement | null>(null);

  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
    onClick?.(e);
    if (e.defaultPrevented) return;
    const target = e.target as HTMLElement;
    if (
      target.closest(
        'button, a, input, select, textarea, [role="button"], [role="link"], [role="combobox"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"]',
      )
    ) {
      return;
    }
    const group = e.currentTarget.closest<HTMLElement>("[data-ui-input-group]");
    const focusable = group?.querySelector<HTMLElement>("input, textarea, select");
    focusable?.focus();
  };

  return (
    <Comp
      data-input-addon
      className={cn(inputAddonStyle({ size: ctx?.size }), className)}
      ref={composeRefs(ref, internalRef)}
      onClick={handleClick}
      {...props}
    />
  );
};

const CompoundInput = Object.assign(Input, {
  Group: InputGroup,
  Addon: InputAddon,
});

export type { InputGroupProps, InputProps };
export { CompoundInput as Input, inputStyle, useInputStyle };

Features

  • Multiple Variants: Supports default and minimal styles
  • Validation States: Built-in support for invalid state
  • Flexible Addons: Support for prefix and suffix elements
  • Interactive Elements: Optional interactive addons for enhanced functionality

Anatomy

          <Input.Group>
  <Input.Addon />
  <Input />
  <Input.Addon />
</Input.Group>
        

Input is for text-like single-line controls: text, email, password, search, tel, url, number, file, and the date/time family (date, time, datetime-local, month, week). For the non-text-like input types, reach for the dedicated primitive: Checkbox, Radio, Slider (for range), ColorPicker, Switch, Toggle, OTPInput, or DatePicker when you want a full picker UI rather than native entry.

API Reference

Input

Extends the input element.

PropDefaultTypeDescription
variant"default""default""minimal"The visual style variant of the input.
size"md""xs""sm""md""lg"The size of the input.
invalid-booleanWhether the input is in an invalid state.

Input.Group

Extends the div element. Wraps an Input together with one or more Input.Addon siblings β€” useful for icon prefixes, text suffixes, password toggles, country pickers, etc.

The group owns the visual frame: border, height, focus ring, invalid/disabled state. Pass size and variant here, not on the inner Input β€” sizing inside the group is inherited from this context, so the inner Input’s own size/variant props are ignored.

PropDefaultType
size"md""xs""sm""md""lg"
variant"default""default""minimal"

Input.Addon

Extends the div element. Renders flush at the group’s edges with size-aware padding inherited from Input.Group. Drop interactive content (a <button>, a Tooltip.Trigger, a Popover.Trigger) directly inside β€” no pointer-events-auto ceremony needed.

Static addons (icons, text) are click-through to the input β€” clicking a leading lock icon focuses the input the way you’d expect. The addon detects whether the click target is interactive (button, a, input, [role="button"], etc.) and only forwards focus when it isn’t, so interactive content keeps its own click semantics.

For component-as-addon composition (a popover trigger, a country picker, etc.), pass asChild and provide your own element. The addon’s flex/sizing/padding classes are merged onto the child.

PropDefaultType
asChild-boolean

Examples

Simple

Basic usage of the input component.

import { Input } from "@/components/input";

export default function InputExample() {
  return (
    <div className="w-90">
      <Input placeholder="Type something..." />
    </div>
  );
}

Minimal

A more subtle variant with minimal styling.

import { Input } from "@/components/input";

export default function InputMinimal() {
  return (
    <div className="w-90">
      <Input variant="minimal" placeholder="Type something..." />
    </div>
  );
}

Sizes

Extra-small
Small
Medium
Large
import { Input } from "@/components/input";

export default function InputSizes() {
  return (
    <div className="w-90 space-y-4">
      <Input.Group size="xs">
        <Input placeholder="Search something" />
        <Input.Addon>Extra-small</Input.Addon>
      </Input.Group>
      <Input.Group size="sm">
        <Input placeholder="Search something" />
        <Input.Addon>Small</Input.Addon>
      </Input.Group>
      <Input.Group size="md">
        <Input placeholder="Search something" />
        <Input.Addon>Medium</Input.Addon>
      </Input.Group>
      <Input.Group size="lg">
        <Input placeholder="Search something" />
        <Input.Addon>Large</Input.Addon>
      </Input.Group>
    </div>
  );
}

Disabled

Example of a disabled input state. Inside a group, the disabled visual cascades to the wrapper via :has(input:disabled), so addons dim with the input.

import { Input } from "@/components/input";

export default function InputDisabled() {
  return (
    <div className="w-90">
      <Input placeholder="Type something..." disabled />
    </div>
  );
}
.com
import { LockIcon } from "@phosphor-icons/react/dist/ssr";

import { Input } from "@/components/input";

export default function InputGroupDisabled() {
  return (
    <div className="w-90">
      <Input.Group>
        <Input.Addon>
          <LockIcon />
        </Input.Addon>
        <Input placeholder="Locked" disabled />
        <Input.Addon>.com</Input.Addon>
      </Input.Group>
    </div>
  );
}

Invalid

Showing validation state with an invalid input. Inside a group, the error border and ring carry across via :has([data-invalid]) β€” works the same whether invalid is set on the inner Input or aria-invalid is injected by Field.Control.

import { Input } from "@/components/input";

export default function InputInvalid() {
  return (
    <div className="w-90">
      <Input placeholder="Type something..." defaultValue="Pedro" invalid />
    </div>
  );
}
import { WarningIcon } from "@phosphor-icons/react/dist/ssr";

import { Input } from "@/components/input";

export default function InputGroupInvalid() {
  return (
    <div className="w-90">
      <Input.Group>
        <Input.Addon>
          <WarningIcon />
        </Input.Addon>
        <Input placeholder="Type something..." defaultValue="not-an-email" invalid />
      </Input.Group>
    </div>
  );
}

Icon

Input with an icon prefix.

import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/ssr";
import { Input } from "@/components/input";

export default function InputIcon() {
  return (
    <div className="w-90 space-y-4">
      <Input.Group>
        <Input.Addon>
          <MagnifyingGlassIcon />
        </Input.Addon>
        <Input placeholder="Search something" />
      </Input.Group>
      <Input.Group variant="minimal">
        <Input.Addon>
          <MagnifyingGlassIcon />
        </Input.Addon>
        <Input placeholder="Search something" />
      </Input.Group>
    </div>
  );
}

Text Addons

Using text prefixes and suffixes.

https://
.significa.co
import { Input } from "@/components/input";

export default function InputTextAddons() {
  return (
    <div className="w-90">
      <Input.Group>
        <Input.Addon>https://</Input.Addon>
        <Input placeholder="subdomain" />
        <Input.Addon>.significa.co</Input.Addon>
      </Input.Group>
    </div>
  );
}

Icon and Action

Combining an icon prefix with an interactive suffix.

import { EyeClosedIcon, EyeIcon, LockIcon } from "@phosphor-icons/react/dist/ssr";
import { useState } from "react";

import { Input } from "@/components/input";

export default function InputIconAction() {
  const [showPassword, setShowPassword] = useState(false);

  return (
    <div className="w-90">
      <Input.Group>
        <Input.Addon>
          <LockIcon />
        </Input.Addon>
        <Input type={showPassword ? "text" : "password"} placeholder="Your password here" />
        <Input.Addon asChild>
          <button
            type="button"
            className="cursor-pointer"
            onClick={() => setShowPassword(!showPassword)}
          >
            {showPassword ? <EyeIcon /> : <EyeClosedIcon />}
          </button>
        </Input.Addon>
      </Input.Group>
    </div>
  );
}

Interactive Addon

Example with clickable addon elements.

import { InfoIcon } from "@phosphor-icons/react/dist/ssr";
import { useRef } from "react";

import { Input } from "@/components/input";
import { Tooltip } from "@/components/tooltip";

export default function InputInteractiveAddon() {
  const input = useRef<HTMLInputElement>(null);

  return (
    <div className="w-90">
      <Input.Group>
        <Input.Addon asChild>
          <button type="button" onClick={() => alert("interactive")}>
            +351
          </button>
        </Input.Addon>
        <Input ref={input} placeholder="000 000 000" />
        <Input.Addon>
          <Tooltip>
            <Tooltip.Trigger>
              <InfoIcon />
            </Tooltip.Trigger>
            <Tooltip.Content>Your phone number will be visible</Tooltip.Content>
          </Tooltip>
        </Input.Addon>
      </Input.Group>
    </div>
  );
}

Component as Addon

Composing a Menu.Trigger (or any other compound primitive) as an addon via asChild. Demonstrates a phone field with a country picker β€” the trigger lives inside the input frame, the menu floats from it with keyboard nav and focus management.

import { CaretDownIcon } from "@phosphor-icons/react/dist/ssr";
import { useState } from "react";

import { Input } from "@/components/input";
import { Menu } from "@/components/menu";

const countries = [
  { code: "PT", name: "Portugal", dial: "+351", flag: "πŸ‡΅πŸ‡Ή" },
  { code: "ES", name: "Spain", dial: "+34", flag: "πŸ‡ͺπŸ‡Έ" },
  { code: "FR", name: "France", dial: "+33", flag: "πŸ‡«πŸ‡·" },
  { code: "DE", name: "Germany", dial: "+49", flag: "πŸ‡©πŸ‡ͺ" },
  { code: "GB", name: "United Kingdom", dial: "+44", flag: "πŸ‡¬πŸ‡§" },
  { code: "US", name: "United States", dial: "+1", flag: "πŸ‡ΊπŸ‡Έ" },
] as const;

type Country = (typeof countries)[number];

// Phone field with a country picker as a leading addon. Demonstrates the
// asChild β†’ asChild chain (`Input.Addon` β†’ `Menu.Trigger` β†’ `<button>`)
// for composing complex interactive content into the input frame. Menu
// brings keyboard nav, focus management, and proper ARIA roles for free.
export default function InputComponentAddon() {
  const [country, setCountry] = useState<Country>(countries[0]);

  return (
    <div className="w-90">
      <Menu>
        <Input.Group>
          <Input.Addon asChild>
            <Menu.Trigger asChild>
              <button
                type="button"
                className="flex cursor-pointer items-center gap-1 text-foreground-secondary transition-colors hover:text-foreground"
              >
                <span className="text-base tabular-nums leading-none">{country.dial}</span>
                <CaretDownIcon className="size-3" />
              </button>
            </Menu.Trigger>
          </Input.Addon>
          <Input type="tel" placeholder="000 000 000" />
        </Input.Group>
        <Menu.Items className="w-64">
          {countries.map((c) => (
            <Menu.Item className="justify-between" key={c.code} onSelect={() => setCountry(c)}>
              <span className="flex items-center gap-2">
                <span className="text-base leading-none">{c.flag}</span>
                <span className="flex-1 truncate">{c.name}</span>
              <span className="text-foreground-secondary text-sm">{c.dial}</span>
            </Menu.Item>
          ))}
        </Menu.Items>
      </Menu>
    </div>
  );
}

File

The native ::file-selector-button is restyled to read as inline text inside the input frame β€” no native border, background, or chunky chrome.

import { Input } from "@/components/input";

export default function InputFileExample() {
  return (
    <div className="w-90">
      <Input type="file" />
    </div>
  );
}

Date & Time

The browser’s calendar/clock picker icon is suppressed in Chromium and absent by default in Safari, so date, time, datetime-local, month, and week render as a clean text frame in both. Firefox keeps its native calendar button on date / datetime-local / month / week (it doesn’t expose a pseudo-element to hide it) β€” reach for DatePicker when you need a unified picker UI across browsers.

import { CalendarIcon, ClockIcon } from "@phosphor-icons/react/dist/ssr";
import { Input } from "@/components/input";

export default function InputDateTime() {
  return (
    <div className="w-90 space-y-4">
      <div className="flex gap-2">
        <Input type="date" defaultValue="2026-05-29" />
        <Input type="time" defaultValue="10:30" />
      </div>
      <Input.Group>
        <Input.Addon>
          <CalendarIcon />
        </Input.Addon>
        <Input type="date" defaultValue="2026-05-29" />
      </Input.Group>
      <Input.Group>
        <Input.Addon>
          <ClockIcon />
        </Input.Addon>
        <Input type="time" defaultValue="10:30" step="1" />
      </Input.Group>
    </div>
  );
}

Best Practices

  1. Validation:

    • Provide clear error messages
    • Use appropriate input types
  2. Addons:

    • Keep icons and text addons relevant
    • Ensure interactive elements are clearly clickable
    • Maintain consistent spacing

Previous

File Upload

Next

Kbd