Przejdź do treści

Popover

Pływający panel przypięty do wyzwalacza, na niewielkie interaktywne treści — ustawienia, szybkie formularze, selektory — który nie blokuje strony.

Kod źródłowyBase UIRejestr
import { SlidersHorizontalIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

const dimensions = [
  { id: "width", label: "Szerokość", value: "100%" },
  { id: "max-width", label: "Maks. szer.", value: "1280px" },
  { id: "height", label: "Wysokość", value: "auto" },
  { id: "max-height", label: "Maks. wys.", value: "none" },
]

export default function PopoverDemo() {
  return (
    <Popover>
      <PopoverTrigger render={<Button variant="outline" />}>
        <SlidersHorizontalIcon data-icon="inline-start" />
        Wymiary
      </PopoverTrigger>
      <PopoverContent className="w-80">
        <PopoverHeader>
          <PopoverTitle>Wymiary</PopoverTitle>
          <PopoverDescription>
            Ustaw rozmiar zaznaczonej ramki.
          </PopoverDescription>
        </PopoverHeader>
        <FieldGroup className="gap-2.5">
          {dimensions.map((dimension) => (
            <Field
              key={dimension.id}
              orientation="horizontal"
              className="grid grid-cols-[6rem_1fr]"
            >
              <FieldLabel
                htmlFor={dimension.id}
                className="font-normal text-muted-foreground"
              >
                {dimension.label}
              </FieldLabel>
              <Input
                id={dimension.id}
                size="sm"
                defaultValue={dimension.value}
              />
            </Field>
          ))}
        </FieldGroup>
      </PopoverContent>
    </Popover>
  )
}

Instalacja

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

Użycie

import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
<Popover>
  <PopoverTrigger render={<Button variant="outline" />}>Dimensions</PopoverTrigger>
  <PopoverContent>
    <PopoverHeader>
      <PopoverTitle>Dimensions</PopoverTitle>
      <PopoverDescription>Set the size of the selected frame.</PopoverDescription>
    </PopoverHeader>
    {/* … */}
  </PopoverContent>
</Popover>

Za pozycjonowanie popovera odpowiada Floating UI: popover otwiera się pod wyzwalaczem, przeskakuje na drugą stronę, gdy brakuje miejsca, i nie wychodzi poza obszar widoku. Pojawia się od strony wyzwalacza — to choreografia popup-motion, wspólna dla wszystkich pływających powierzchni prfct.

Przykłady

Szybkie wprowadzanie danych

Popover może zawierać mały formularz. Steruj nim przez open i onOpenChange, aby zamykał się po udanym wysłaniu, a potwierdzenie pokaż w powiadomieniu Toast.

"use client"

import * as React from "react"
import { MessageSquareIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"

export default function PopoverFeedback() {
  const [open, setOpen] = React.useState(false)
  const [message, setMessage] = React.useState("")

  function send(event: React.FormEvent) {
    event.preventDefault()
    setOpen(false)
    setMessage("")
    toast.add({ title: "Dziękujemy za opinię", type: "success" })
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger render={<Button variant="outline" />}>
        <MessageSquareIcon data-icon="inline-start" />
        Przekaż opinię
      </PopoverTrigger>
      <PopoverContent align="end" className="w-80">
        <form onSubmit={send} className="flex flex-col gap-3">
          <Field>
            <FieldLabel htmlFor="feedback-message">
              Co możemy poprawić na tej stronie?
            </FieldLabel>
            <Textarea
              id="feedback-message"
              placeholder="Pomysły, błędy albo cokolwiek, co cię spowolniło…"
              value={message}
              onChange={(event) => setMessage(event.target.value)}
              className="min-h-24"
            />
          </Field>
          <div className="flex items-center justify-between gap-2">
            <span className="text-xs text-muted-foreground">
              Odpowiemy na twój adres e-mail.
            </span>
            <Button type="submit" size="sm" disabled={!message.trim()}>
              Wyślij
            </Button>
          </div>
        </form>
      </PopoverContent>
    </Popover>
  )
}

PopoverHeader, PopoverTitle i PopoverDescription nadają popoverowi tytuł, który nazywa go dla technologii wspomagających. Używaj ich zawsze, gdy treść zawiera więcej niż jedną kontrolkę.

import { SettingsIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"

const options = [
  {
    id: "wrap",
    label: "Zawijaj długie wiersze",
    description: "Miękkie zawijanie na szerokości edytora.",
    checked: true,
  },
  {
    id: "minimap",
    label: "Pokaż minimapę",
    description: "Podgląd całego pliku po prawej stronie.",
    checked: false,
  },
  {
    id: "format",
    label: "Formatuj przy zapisie",
    description: "Uruchamia Prettier przed każdym zapisem.",
    checked: true,
  },
]

export default function PopoverSettings() {
  return (
    <Popover>
      <PopoverTrigger
        render={
          <Button variant="ghost" size="icon" aria-label="Ustawienia edytora" />
        }
      >
        <SettingsIcon />
      </PopoverTrigger>
      <PopoverContent align="start" className="w-80 gap-4">
        <PopoverHeader>
          <PopoverTitle>Ustawienia edytora</PopoverTitle>
          <PopoverDescription>
            Dotyczą każdego otwieranego pliku.
          </PopoverDescription>
        </PopoverHeader>
        <Separator />
        <FieldGroup className="gap-4">
          {options.map((option) => (
            <Field key={option.id} orientation="horizontal">
              <FieldContent>
                <FieldLabel htmlFor={option.id}>{option.label}</FieldLabel>
                <FieldDescription>{option.description}</FieldDescription>
              </FieldContent>
              <Switch
                id={option.id}
                size="sm"
                defaultChecked={option.checked}
              />
            </Field>
          ))}
        </FieldGroup>
      </PopoverContent>
    </Popover>
  )
}

Położenie

side ustawia popover po wybranej stronie wyzwalacza: top, right, bottom (domyślnie) lub left. Jeśli po preferowanej stronie brakuje miejsca, popover przeskakuje na przeciwną.

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTrigger,
} from "@/components/ui/popover"

const sides = ["top", "right", "bottom", "left"] as const

export default function PopoverSides() {
  return (
    <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
      {sides.map((side) => (
        <Popover key={side}>
          <PopoverTrigger
            render={<Button variant="outline" className="capitalize" />}
          >
            {side}
          </PopoverTrigger>
          <PopoverContent side={side} className="w-56">
            <PopoverDescription>
              Otwiera się po stronie{" "}
              <strong className="font-medium text-foreground">{side}</strong>, a gdy brakuje miejsca — po przeciwnej.
            </PopoverDescription>
          </PopoverContent>
        </Popover>
      ))}
    </div>
  )
}

Wyrównanie

align wyrównuje popover do początku (start), środka (center, domyślnie) lub końca (end) wyzwalacza. Wyrównanie do początku sprawdza się w menu czytanych od lewej do prawej, a do końca — przy wyzwalaczach blisko prawej krawędzi.

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTrigger,
} from "@/components/ui/popover"

const alignments = ["start", "center", "end"] as const

export default function PopoverAlign() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-2">
      {alignments.map((align) => (
        <Popover key={align}>
          <PopoverTrigger
            render={<Button variant="outline" className="w-28 capitalize" />}
          >
            {align}
          </PopoverTrigger>
          <PopoverContent align={align} className="w-60">
            <PopoverDescription>
              Wyrównanie{" "}
              <strong className="font-medium text-foreground">{align}</strong>{" "}
              względem wyzwalacza.
            </PopoverDescription>
          </PopoverContent>
        </Popover>
      ))}
    </div>
  )
}

Wytyczne

Kiedy używać

  • Do kontrolek działających na jednym elemencie: formatowania, wymiarów, filtrów pojedynczej kolumny.
  • Do szybkiego wprowadzania danych, które nie wymaga pełnego okna dialogowego: notatki, zmiany nazwy, daty.
  • Do wybierania: kolorów, emoji, dat.

Kiedy nie używać

  • Do pokazania zwykłej etykiety tekstowej — użyj Tooltip.
  • Do podglądu linku po najechaniu — użyj Hover Card.
  • Do listy akcji — użyj Dropdown Menu, które zapewnia nawigację strzałkami i wybór przez wpisywanie.
  • Do zadań, które wymagają skupienia i wyraźnego zakończenia — użyj Dialog.

Niech pozostanie mały

Popover to rzut oka, a nie miejsce docelowe. Jeśli jego treść wymaga przewijania, więcej niż kilku kontrolek albo przycisku Zapisz, wyrosła już z popovera. Trzymaj szerokość poniżej ~20 rem, a wysokość — w granicach kilku wierszy.

Czytanie
Zawijaj wiersze
Numery wierszy
DobrzeKilka kontrolek, których zmiany obowiązują od razu.
Edytuj profil
ŹleFormularz, który trzeba przewijać i zapisywać, powinien trafić do okna dialogowego lub panelu.

Dostępność

Wyzwalacz to przycisk z aria-expanded i aria-haspopup="dialog"; popup to niemodalne okno dialogowe, którego etykietą jest PopoverTitle, jeśli występuje.

KlawiszDziałanie
EnterSpacja
Na wyzwalaczu: otwiera popover i przenosi fokus na jego pierwszy fokusowalny element albo zamyka popover, gdy jest otwarty.
Tab
Przechodzi przez kontrolki popovera i dalej, do reszty strony.
Esc
Zamyka popover i przywraca fokus na wyzwalacz.
  • Domyślnie niemodalny. Strona pozostaje interaktywna; kliknięcie poza popoverem albo przeniesienie fokusu gdzie indziej go zamyka. Przekaż modal, aby zablokować przewijanie strony i interakcje wskaźnikiem poza popoverem. Fokus jest zatrzymywany w środku, przy modal i przy modal="trap-focus", tylko wtedy, gdy popup zawiera PopoverClose: bez przycisku, którym da się wyjść za pomocą klawiatury, Base UI pozwala klawiszowi Tab opuścić popover.
  • Nazwij go. Używaj PopoverTitle w każdym popoverze z kontrolkami. Jeśli nie ma widocznego tytułu, dodaj aria-label do PopoverContent.
  • Otwieranie po najechaniu trzeba włączyć. openOnHover na wyzwalaczu sprawia, że popover otwiera się także po najechaniu, ale zawsze otwiera się też po kliknięciu i z klawiatury — nigdy nie rób z najechania jedynej drogi dostępu.

Dokumentacja API

Popover

Komponent główny. Nie renderuje żadnego elementu. Przyjmuje wszystkie propsy Popover.Root z Base UI.

PropTypDomyślnie
open

Czy popover jest otwarty. Aby nim sterować, używaj razem z onOpenChange.

booleanBrak wartości domyślnej
defaultOpen

Czy popover jest otwarty na starcie, gdy jest niekontrolowany.

booleanfalse
onOpenChange

Wywoływana przy otwarciu lub zamknięciu.

(open: boolean, details) => voidBrak wartości domyślnej
modal

true blokuje przewijanie strony i interakcje wskaźnikiem poza popoverem. "trap-focus" nie blokuje żadnego z nich. Obie wartości zatrzymują fokus w środku tylko wtedy, gdy popup zawiera PopoverClose.

boolean | "trap-focus"false
actionsRef

Imperatywny uchwyt do zamknięcia lub odmontowania popovera.

RefObject<{ close, unmount }>Brak wartości domyślnej

PopoverTrigger

Otwiera i zamyka popover. Renderuje <button>; użyj render, aby wyrenderować Button z prfct.

PropTypDomyślnie
openOnHover

Otwiera popover także po najechaniu na wyzwalacz.

booleanfalse
delay

Opóźnienie otwarcia po najechaniu, w ms. Wymaga openOnHover.

number300
closeDelay

Opóźnienie zamknięcia popovera otwartego najechaniem, w ms.

number0

PopoverContent

Renderuje portal, element pozycjonujący i popup.

PropTypDomyślnie
side

Preferowana strona wyzwalacza. Zmienia się na przeciwną, gdy brakuje miejsca.

"top" | "right" | "bottom" | "left" | "inline-start" | "inline-end""bottom"
sideOffset

Odległość od wyzwalacza, w px.

number6
align

Wyrównanie względem wyzwalacza.

"start" | "center" | "end""center"
alignOffset

Przesunięcie wzdłuż osi wyrównania, w px.

number0
initialFocus

Element, który dostaje fokus po otwarciu.

boolean | RefObject | (interaction) => HTMLElement | booleanBrak wartości domyślnej
finalFocus

Element, który dostaje fokus po zamknięciu.

boolean | RefObject | (interaction) => HTMLElement | booleanBrak wartości domyślnej
className

Łączona z klasami popupu. Domyślna szerokość to w-72.

stringBrak wartości domyślnej

PopoverHeader, PopoverTitle, PopoverDescription

PopoverHeader układa tytuł i opis jeden pod drugim. PopoverTitle renderuje <h2>, który jest etykietą popovera; PopoverDescription renderuje <p>, który go opisuje.