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

'use client';

import type { VariantProps } from 'cva';
import {
  Children,
  createContext,
  isValidElement,
  use,
  useLayoutEffect,
  useRef,
  useState,
} from 'react';

import { Slot } from '@/components/slot';
import { composeRefs } from '@/lib/compose-refs';
import { cn, cva } from '@/lib/utils/classnames';

const inputStyle = cva({
  base: [
    'transition',
    'h-10 w-full rounded-xl border px-4 py-1 font-medium text-base',
    'text-foreground/80 placeholder:text-foreground-secondary focus:outline-none focus-visible:border-accent focus-visible:text-foreground focus-visible:ring-4 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-invalid:border-red-500 data-invalid:focus-visible:border-red-500 data-invalid:focus-visible:ring-red-500/20 data-invalid:hover:border-red-600',
    'pl-(--prefix-width,calc(var(--spacing)*4))',
    'pr-(--suffix-width,calc(var(--spacing)*4))',
  ],
  variants: {
    variant: {
      default:
        'border-border bg-background shadow-xs hover:border-mix-border/8',
      minimal:
        'border-transparent bg-transparent hover:bg-background-secondary focus-visible:bg-background',
    },
  },
  defaultVariants: {
    variant: 'default',
  },
});

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

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

interface InputGroupContextType {
  prefixWidth: number;
  suffixWidth: number;
  setPrefixWidth: (width: number) => void;
  setSuffixWidth: (width: number) => void;
}

const InputGroupContext = createContext<InputGroupContextType>({
  prefixWidth: 0,
  suffixWidth: 0,
  setPrefixWidth: () => {},
  setSuffixWidth: () => {},
});

const useInputGroup = () => {
  const ctx = use(InputGroupContext);

  if (!ctx) {
    throw new Error('InputGroup must be used within an InputGroupContext');
  }

  return ctx;
};

const InputGroup = ({
  className,
  style,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  const [prefixWidth, setPrefixWidth] = useState(0);
  const [suffixWidth, setSuffixWidth] = useState(0);

  // check if there are more than one InputPrefix or InputSuffix
  const tooManyPrefixes =
    Children.toArray(props.children).filter(
      (child) => isValidElement(child) && child.type === InputPrefix
    ).length > 1;

  const tooManySuffixes =
    Children.toArray(props.children).filter(
      (child) => isValidElement(child) && child.type === InputSuffix
    ).length > 1;

  if (tooManyPrefixes || tooManySuffixes) {
    throw new Error(
      'InputGroup cannot have more than one InputPrefix or InputSuffix'
    );
  }

  return (
    <InputGroupContext
      value={{ prefixWidth, suffixWidth, setPrefixWidth, setSuffixWidth }}
    >
      <div
        className={cn('relative', className)}
        style={{
          ...(prefixWidth > 0 && {
            '--prefix-width': `calc(${prefixWidth}px + var(--spacing)*5.5)`,
          }),
          ...(suffixWidth > 0 && {
            '--suffix-width': `calc(${suffixWidth}px + var(--spacing)*5.5)`,
          }),
          ...style,
        }}
        {...props}
      />
    </InputGroupContext>
  );
};

interface InputPrefixProps extends React.ComponentPropsWithRef<'div'> {
  asChild?: boolean;
}

const InputPrefix = ({ className, ...props }: InputPrefixProps) => {
  const { setPrefixWidth } = useInputGroup();

  return (
    <InputAddon
      {...props}
      className={cn('left-4', className)}
      onSetWidth={setPrefixWidth}
    />
  );
};

interface InputSuffixProps extends React.ComponentPropsWithRef<'div'> {
  asChild?: boolean;
}

const InputSuffix = ({ className, ...props }: InputSuffixProps) => {
  const { setSuffixWidth } = useInputGroup();

  return (
    <InputAddon
      {...props}
      className={cn('right-4', className)}
      onSetWidth={setSuffixWidth}
    />
  );
};

interface InputAddonProps extends React.ComponentPropsWithRef<'div'> {
  onSetWidth?: (width: number) => void;
  asChild?: boolean;
}

const InputAddon = ({
  ref,
  className,
  onSetWidth,
  asChild,
  ...props
}: InputAddonProps) => {
  const Comp = asChild ? Slot : 'div';
  const internalRef = useRef<HTMLDivElement | null>(null);

  useLayoutEffect(() => {
    onSetWidth?.(internalRef.current?.offsetWidth ?? 0);

    const observer = new ResizeObserver(([entry]) => {
      if (entry?.contentRect.width) {
        onSetWidth?.(Math.round(entry.contentRect.width));
      }
    });

    if (internalRef.current) {
      observer.observe(internalRef.current);
    }

    return () => observer.disconnect();
  }, [onSetWidth]);

  return (
    <Comp
      data-input-addon
      className={cn(
        'absolute top-1/2 flex -translate-y-1/2 items-center justify-center font-medium text-base',
        'pointer-events-none text-foreground',
        className
      )}
      ref={composeRefs(ref, internalRef)}
      {...props}
    />
  );
};

export { Input, InputGroup, InputPrefix, InputSuffix, inputStyle };

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


          <InputGroup>
  <InputPrefix />
  <Input />
  <InputSuffix />
</InputGroup>
        

API Reference

Input

Extends the input element.

Prop Default Type Description
variant "default" "default""minimal" The visual style variant of the input.
invalid - boolean Whether the input is in an invalid state.

InputGroup

Extends the div element.

This component is used to wrap the input when you want to add a prefix or suffix (e.g.: search icon, currency symbol, etc.)

InputPrefix

Extends the div element.

Prop Default Type
asChild - boolean

InputSuffix

Extends the div element.

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

Disabled

Example of a disabled input state.

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

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

Invalid

Showing validation state with an invalid input.

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

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

Icon

Input with an icon prefix.

import { MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr';

import { Input, InputGroup, InputPrefix } from '@/components/input';

export default function InputIcon() {
  return (
    <div className="w-90">
      <InputGroup>
        <InputPrefix>
          <MagnifyingGlassIcon />
        </InputPrefix>
        <Input placeholder="Search something" />
      </InputGroup>
    </div>
  );
}

Text Addons

Using text prefixes and suffixes.

https://
.significa.co
import {
  Input,
  InputGroup,
  InputPrefix,
  InputSuffix,
} from '@/components/input';

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

Icon and Action

Combining an icon prefix with an interactive suffix.

'use client';

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

import {
  Input,
  InputGroup,
  InputPrefix,
  InputSuffix,
} from '@/components/input';

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

  return (
    <div className="w-90">
      <InputGroup>
        <InputPrefix>
          <LockIcon />
        </InputPrefix>
        <Input
          type={showPassword ? 'text' : 'password'}
          placeholder="Your password here"
        />
        <InputSuffix className="pointer-events-auto">
          <button
            type="button"
            className="cursor-pointer"
            onClick={() => setShowPassword(!showPassword)}
          >
            {showPassword ? <EyeIcon /> : <EyeClosedIcon />}
          </button>
        </InputSuffix>
      </InputGroup>
    </div>
  );
}

Interactive Addon

Example with clickable addon elements.

'use client';

import { InfoIcon } from '@phosphor-icons/react';
import { useRef } from 'react';

import {
  Input,
  InputGroup,
  InputPrefix,
  InputSuffix,
} from '@/components/input';
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/tooltip';

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

  return (
    <div className="w-90">
      <InputGroup>
        <InputPrefix className="pointer-events-auto" asChild>
          <button type="button" onClick={() => alert('interactive')}>
            +351
          </button>
        </InputPrefix>
        <Input ref={input} placeholder="000 000 000" />
        <InputSuffix className="pointer-events-auto">
          <Tooltip>
            <TooltipTrigger>
              <InfoIcon />
            </TooltipTrigger>
            <TooltipContent>Your phone number will be visible</TooltipContent>
          </Tooltip>
        </InputSuffix>
      </InputGroup>
    </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

Dropdown

Next

Label