Przejdź do treści

Input Group

Pole z dołączonymi ikonami, tekstem, przyciskami lub podpowiedziami klawiszy — z jednym obramowaniem, fokusem i walidacją, jak pojedyncza kontrolka.

Kod źródłowyRejestr
⌘K
import { SearchIcon } from "lucide-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Kbd } from "@/components/ui/kbd"

export default function InputGroupDemo() {
  return (
    <InputGroup className="w-72">
      <InputGroupAddon>
        <SearchIcon />
      </InputGroupAddon>
      <InputGroupInput
        placeholder="Szukaj zgłoszeń…"
        aria-label="Szukaj zgłoszeń"
      />
      <InputGroupAddon align="inline-end">
        <Kbd>⌘K</Kbd>
      </InputGroupAddon>
    </InputGroup>
  )
}

Anatomia

https://
  1. 1GrupaJedno obramowane pole z własną poświatą fokusu, dzięki czemu dodatki i pole odbiera się jako jedną kontrolkę.
  2. 2Dodatek początkowyTekst lub ikona przed polem: protokół, waluta, ikona wyszukiwania. Kliknięcie w dodatek ustawia fokus w polu.
  3. 3KontrolkaSamo pole — jedno- lub wielowierszowe — bez obramowania wewnątrz grupy.
  4. 4Dodatek końcowyPrzycisk, jednostka lub podpowiedź klawiszowa za polem.

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/input-group.json

Użycie

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"
<InputGroup>
  <InputGroupAddon>
    <SearchIcon />
  </InputGroupAddon>
  <InputGroupInput placeholder="Search…" />
</InputGroup>

Wewnątrz grupy używaj InputGroupInput i InputGroupTextarea — nigdy zwykłego komponentu Input. Obramowanie, wypełnienie, poświata fokusu i stan błędu należą do grupy; wewnętrzna kontrolka jest celowo pozbawiona własnych stylów.

Przykłady

Dodatki tekstowe

Prefiksy i sufiksy niosą jednostki i stałe części wartości — walutę, domenę — dzięki czemu ludzie wpisują tylko to, co się zmienia. Owiń tekst w InputGroupText.

.prfct.dev
$
USD
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"

export default function InputGroupTextAddons() {
  return (
    <FieldGroup className="w-full max-w-sm">
      <Field>
        <FieldLabel htmlFor="input-group-subdomain">Adres URL przestrzeni roboczej</FieldLabel>
        <InputGroup>
          <InputGroupInput
            id="input-group-subdomain"
            defaultValue="acme"
            className="text-right"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupText>.prfct.dev</InputGroupText>
          </InputGroupAddon>
        </InputGroup>
      </Field>
      <Field>
        <FieldLabel htmlFor="input-group-price">Budżet miesięczny</FieldLabel>
        <InputGroup>
          <InputGroupAddon>
            <InputGroupText>$</InputGroupText>
          </InputGroupAddon>
          <InputGroupInput
            id="input-group-price"
            inputMode="decimal"
            defaultValue="2400,00"
            className="tabular-nums"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupText>USD</InputGroupText>
          </InputGroupAddon>
        </InputGroup>
      </Field>
    </FieldGroup>
  )
}

Przyciski

InputGroupButton to kompaktowy Button w wariancie ghost, dopasowany rozmiarem do wnętrza pola: skopiuj wartość, odsłoń hasło, wyczyść zapytanie. Przyciski z samą ikoną potrzebują aria-label; przełączniki takie jak Pokaż hasło zgłaszają też aria-pressed.

"use client"

import * as React from "react"
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react"

import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"

export default function InputGroupButtons() {
  const [visible, setVisible] = React.useState(false)
  const [copied, setCopied] = React.useState(false)
  const key = "sk_live_51Mz9aQ8dK2x"

  React.useEffect(() => {
    if (!copied) return
    const timer = setTimeout(() => setCopied(false), 1500)
    return () => clearTimeout(timer)
  }, [copied])

  async function copy() {
    try {
      await navigator.clipboard.writeText(key)
      setCopied(true)
    } catch {
      // The clipboard can be denied; the key stays in the field to select.
    }
  }

  return (
    <FieldGroup className="w-full max-w-sm">
      <Field>
        <FieldLabel htmlFor="input-group-key">Klucz API</FieldLabel>
        <InputGroup>
          <InputGroupInput
            id="input-group-key"
            readOnly
            value={key}
            className="font-mono"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              size="icon-xs"
              aria-label={copied ? "Skopiowano" : "Kopiuj klucz API"}
              onClick={copy}
            >
              {copied ? (
                <CheckIcon className="text-success-11" />
              ) : (
                <CopyIcon />
              )}
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </Field>
      <Field>
        <FieldLabel htmlFor="input-group-password">Hasło</FieldLabel>
        <InputGroup>
          <InputGroupInput
            id="input-group-password"
            type={visible ? "text" : "password"}
            defaultValue="parallel-lines-meet"
            autoComplete="current-password"
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              size="icon-xs"
              aria-label={visible ? "Ukryj hasło" : "Pokaż hasło"}
              aria-pressed={visible}
              onClick={() => setVisible((value) => !value)}
            >
              {visible ? <EyeOffIcon /> : <EyeIcon />}
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </Field>
    </FieldGroup>
  )
}

Status

Dodatki mogą informować, co dzieje się z wartością: podczas sprawdzania dostępności pokazują Spinner, a potem potwierdzają wynik ikoną. Zawsze powtórz status tekstem pod polem — sama ikona nie wystarczy.

@
@
import { CircleCheckIcon } from "lucide-react"

import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Spinner } from "@/components/ui/spinner"

export default function InputGroupStatus() {
  return (
    <FieldGroup className="w-full max-w-sm">
      <Field>
        <FieldLabel htmlFor="input-group-checking">Nazwa użytkownika</FieldLabel>
        <InputGroup>
          <InputGroupAddon>
            <span className="text-muted-foreground">@</span>
          </InputGroupAddon>
          <InputGroupInput id="input-group-checking" defaultValue="ada" />
          <InputGroupAddon align="inline-end">
            <Spinner label="Sprawdzanie dostępności" />
          </InputGroupAddon>
        </InputGroup>
        <FieldDescription>Sprawdzanie dostępności…</FieldDescription>
      </Field>
      <Field>
        <FieldLabel htmlFor="input-group-available">Nazwa użytkownika</FieldLabel>
        <InputGroup>
          <InputGroupAddon>
            <span className="text-muted-foreground">@</span>
          </InputGroupAddon>
          <InputGroupInput
            id="input-group-available"
            defaultValue="ada.lovelace"
          />
          <InputGroupAddon align="inline-end">
            <CircleCheckIcon className="text-success-11" aria-hidden="true" />
          </InputGroupAddon>
        </InputGroup>
        <FieldDescription className="text-success-11">
          Nazwa ada.lovelace jest dostępna.
        </FieldDescription>
      </Field>
    </FieldGroup>
  )
}

Pole wiadomości

Z InputGroupTextarea i dodatkiem block-end grupa staje się polem do pisania wiadomości: pole rośnie razem z tekstem, a załączniki, podpowiedzi i przycisk wysyłania pozostają przypięte pod nim.

Enter wysyła · Shift + Enter dodaje nowy wiersz
import { ArrowUpIcon, AtSignIcon, PaperclipIcon } from "lucide-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group"

export default function InputGroupComposer() {
  return (
    <InputGroup className="w-full max-w-lg">
      <InputGroupTextarea
        placeholder="Odpowiedz w #design-reviews…"
        aria-label="Wiadomość"
        className="min-h-20"
      />
      <InputGroupAddon align="block-end" className="gap-1">
        <InputGroupButton size="icon-sm" aria-label="Załącz plik">
          <PaperclipIcon />
        </InputGroupButton>
        <InputGroupButton size="icon-sm" aria-label="Oznacz kogoś">
          <AtSignIcon />
        </InputGroupButton>
        <InputGroupText className="ml-auto text-xs">
          Enter wysyła · Shift + Enter dodaje nowy wiersz
        </InputGroupText>
        <InputGroupButton
          size="icon-sm"
          variant="default"
          aria-label="Wyślij wiadomość"
          className="ml-2 rounded-full"
        >
          <ArrowUpIcon />
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  )
}

Dodatki blokowe

Dodatki block-start stoją nad kontrolką na całą szerokość — nazwa pliku nad edytorem kodu, pasek narzędzi nad notatką. Dodaj border-b, żeby je oddzielić.

webhook-payload.json
import { CodeIcon } from "lucide-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group"

export default function InputGroupBlockAddon() {
  return (
    <InputGroup className="w-full max-w-lg">
      <InputGroupAddon align="block-start" className="border-b pb-2.5">
        <CodeIcon />
        <InputGroupText className="font-mono text-xs text-foreground">
          webhook-payload.json
        </InputGroupText>
      </InputGroupAddon>
      <InputGroupTextarea
        aria-label="Treść webhooka"
        className="min-h-28 font-mono text-code"
        defaultValue={"{\n  \"event\": \"invoice.paid\",\n  \"amount\": 4900\n}"}
      />
    </InputGroup>
  )
}

Wytyczne

Kiedy używać

  • Jednostki, prefiksy i sufiksy, które są częścią wartości: $, USD, .prfct.dev, https://.
  • Akcje działające na samej wartości: kopiowanie, odsłanianie, czyszczenie, wysłanie wyszukiwania.
  • Sygnały dotyczące wartości: ikona wyszukiwania na początku, status walidacji, podpowiedź skrótu klawiszowego.

Kiedy nie używać

  • Akcje, które nie dotyczą wartości, takie jak Zapisz czy Anuluj — umieść wtedy Button obok pola.
  • Więcej niż dwa dodatki po jednej stronie. Zatłoczone pole trudno objąć wzrokiem, a na wpisywanie zostaje mało miejsca.
  • Wielowierszowa edycja z formatowaniem — użyj dedykowanego edytora.

Dodatki nie mogą udawać

Ikona na początku pola ma wyjaśniać, do czego ono służy, a nie je zdobić. Jeśli jest interaktywna, musi być przyciskiem InputGroupButton z etykietą — ikona, która wygląda na klikalną, a nic nie robi, to pułapka.

.prfct.dev
DobrzeDodatki tekstowe niosą stałe części adresu; ludzie wpisują tylko tę, która się zmienia.
ŹleProśba o pełny adres URL zachęca do literówek w częściach, których nikt nie powinien musieć wpisywać.

Dostępność

  • Jedna kontrolka, jedna nazwa. Nazwij InputGroupInput przez FieldLabel z htmlFor/id albo przez aria-label. Tekst dodatku, np. USD, nie wchodzi w skład nazwy — jeśli jednostka ma znaczenie, podaj ją w etykiecie lub opisie.
  • Grupowanie. Grupa i każdy dodatek renderują role="group"; kliknięcie w puste miejsce dodatku ustawia fokus w polu.
  • Fokus i stan. Gdy kontrolka ma fokus — z klawiatury albo po kliknięciu — cała grupa pokazuje poświatę fokusu w kolorze marki, a gdy kontrolka ma aria-invalid — robi się czerwona.
  • Przyciski. InputGroupButton to prawdziwy przycisk, domyślnie z type="button", więc nigdy przypadkiem nie wyśle formularza.
  • Skróty. Podpowiedź Kbd jest wyłącznie wizualna. Zadbaj, żeby skrót dało się odkryć także gdzie indziej, np. w podpowiedzi lub na stronie pomocy.
KlawiszDziałanie
Tab
Przenosi fokus do pola, a potem na kolejne przyciski w grupie.
EnterSpacja
Aktywuje InputGroupButton, który ma fokus.

Dokumentacja API

InputGroup

Renderuje <div role="group">, który rysuje obramowanie, wypełnienie i stan fokusu. Przyjmuje wszystkie propsy div.

InputGroupAddon

Renderuje <div role="group"> na ikony, tekst, przyciski lub podpowiedzi Kbd.

PropTypDomyślnie
align

Przed kontrolką lub za nią w tej samej linii albo nad nią lub pod nią na całą szerokość.

"inline-start" | "inline-end" | "block-start" | "block-end""inline-start"

InputGroupButton

Renderuje Button o rozmiarze dopasowanym do grupy. Przyjmuje wszystkie propsy Button; size przyjmuje własne rozmiary grupy, wymienione poniżej.

PropTypDomyślnie
size

Wysokość 24 lub 28 px; rozmiary icon-xs i icon-sm są kwadratowe.

"xs" | "sm" | "icon-xs" | "icon-sm""xs"
variant

Dowolny wariant Button. Dla głównej akcji, np. Wyślij, użyj default.

Button variant"ghost"
type

Domyślnie button, więc przycisk nigdy przypadkiem nie wyśle formularza.

"button" | "submit" | "reset""button"

InputGroupText

Renderuje <span> na tekst i ikony w kolorze muted-foreground.

InputGroupInput

Renderuje Input z Base UI bez obramowania. Przyjmuje wszystkie natywne propsy pola input.

InputGroupTextarea

Renderuje element <textarea> bez obramowania, który rośnie razem z treścią. Przyjmuje wszystkie natywne propsy textarea.