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';

// Shared visual frame for both standalone `<Input>` and `<Input.Group>`: the
// border, focus ring, invalid/disabled states, variant colors, height, and
// border-radius. The two CVAs below specialize this with size variants that
// either include or exclude horizontal padding (`px-*`) and `gap-*`.
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',
  ],
};

// Vertical inset uses `--inset` (= `--radius * 2`) so the group's vertical
// breathing room scales with the radius dial β€” keeping addon/stepper corners
// concentric across the dial, not just at the default `--radius`.
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 inputHorizontalPaddingBySize = {
  xs: 'gap-1 px-2',
  sm: 'gap-1 px-3',
  md: 'gap-2 px-4',
  lg: 'gap-2 px-5',
};

// Standalone <Input> β€” owns its own horizontal padding (text inset).
// Also exported for primitives that style their own native control with
// input-like chrome (Textarea, OTPInput, DatePicker trigger, Listbox search,
// Select).
const inputStyle = cva({
  base: inputFrameBase,
  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',
  },
});

// Group wrapper β€” same frame, no horizontal padding. Addons own outer padding
// at the edges; the in-group input owns its own text padding.
const inputGroupStyle = cva({
  base: inputFrameBase,
  variants: {
    variant: inputFrameVariants,
    size: inputFrameSize,
  },
  defaultVariants: {
    variant: 'default',
    size: 'md',
  },
});

// Adjacent-to-addon: tighten the input's `px` by one step on the side that
// touches an addon, so the addon-to-text gap doesn't read as a doubled
// `px-{size}`. `[[data-input-addon]+&]:pl-*` matches when an addon precedes
// the input; `[&:has(+[data-input-addon])]:pr-*` matches when one follows.
const inputInGroupStyle = cva({
  base: ['h-full w-full min-w-8 cursor-[inherit] outline-none'],
  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',
  },
});

// Addons carry padding only on their outer edge (the side that touches the
// group's border). The inner edge has none β€” the input's own `px-{size}`
// provides the gap to its content. This prevents addon `px` and input `px`
// from stacking and creating an oversized icon-to-text gap.
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);

// Inside a group, sizing is inherited from the group context (the group decides
// the size, the input fills it). Standalone, the input picks size/variant from
// its own props.
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);

  // Static addons (icons, text) pass clicks through to focus the input β€” the
  // expected behavior for "leading icon" / "currency prefix" patterns. If the
  // click hit something interactive, let it handle the click instead. For
  // `asChild`, Slot's prop merge gives the child's `onClick` precedence over
  // ours, so interactive composed elements keep their own semantics.
  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>
        

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>
  );
}

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