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
      data-invalid={invalid}
      aria-invalid={invalid}
      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.

Prop Default Type Description
variant "default" "default""minimal" The visual style variant of the input.
size "md" "xs""sm""md""lg" The size of the input.
invalid - boolean Whether 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.

Prop Default Type
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.

Prop Default Type
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