Przejdź do treści

Accordion

Stos nagłówków, z których każdy odsłania sekcję treści — stopniowe ujawnianie informacji drugorzędnych.

Kod źródłowyBase UIRejestr

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

export default function AccordionDemo() {
  return (
    <Accordion defaultValue={["shipping"]} className="w-full max-w-sm">
      <AccordionItem value="shipping">
        <AccordionTrigger>Ile trwa wysyłka?</AccordionTrigger>
        <AccordionContent>
          Zamówienia wysyłamy w ciągu jednego dnia roboczego. Dostawa trwa 2–4 dni w UE i 5–8 dni w pozostałych krajach.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="returns">
        <AccordionTrigger>Czy mogę zwrócić produkt?</AccordionTrigger>
        <AccordionContent>
          Tak — w ciągu 30 dni, jeśli produkt jest w oryginalnym stanie. Dla członków klubu zwroty są bezpłatne.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="warranty">
        <AccordionTrigger>Czy produkty mają gwarancję?</AccordionTrigger>
        <AccordionContent>
          Każdy produkt jest objęty dwuletnią gwarancją na wady fabryczne.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}

Anatomia

  1. 1WyzwalaczPrzycisk w nagłówku, który nazywa sekcję. Przełącza ją kliknięcie w dowolnym miejscu wiersza.
  2. 2ElementJedna sekcja: wyzwalacz i jego panel. Elementy są rozdzielone cienkimi liniami.
  3. 3IkonaWskazuje w dół, gdy sekcja jest zamknięta, a po jej otwarciu odwraca się.
  4. 4PanelTreść sekcji. Przy otwieraniu i zamykaniu animuje swoją wysokość, a zamknięty jest pomijany przy przechodzeniu klawiszem Tab.

Instalacja

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

Użycie

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
<Accordion defaultValue={["shipping"]}>
  <AccordionItem value="shipping">
    <AccordionTrigger>How long does shipping take?</AccordionTrigger>
    <AccordionContent>Orders ship within one business day.</AccordionContent>
  </AccordionItem>
</Accordion>

defaultValue i value to zawsze tablice wartości elementów — nawet wtedy, gdy otwarty może być tylko jeden element. Nadaj każdemu elementowi stałą wartość value, żeby jego stan przetrwał ponowne renderowanie i dało się nim sterować.

Przykłady

FAQ

Klasyczne zastosowanie: wiele pytań, z których każdego czytelnika dotyczy tylko kilka. Domyślnie otwarty jest jeden element naraz, więc lista pozostaje krótka i łatwa do przejrzenia, gdy ktoś przechodzi od jednej odpowiedzi do drugiej.

Najczęściej zadawane pytania

Wszystko o planach, rozliczeniach i twoich danych.

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

const questions = [
  {
    value: "seats",
    question: "Jak rozliczane są licencje?",
    answer:
      "Płacisz tylko za aktywnych członków. Za osobę, która nie zalogowała się w danym okresie rozliczeniowym, nie pobieramy opłaty, a licencje dla osób dodanych w trakcie cyklu rozliczamy proporcjonalnie co do dnia.",
  },
  {
    value: "switch",
    question: "Czy mogę później zmienić plan?",
    answer:
      "W każdej chwili. Przejście na wyższy plan działa od razu, a na niższy — od końca bieżącego okresu rozliczeniowego, więc nie tracisz dostępu do opłaconych funkcji.",
  },
  {
    value: "trial",
    question: "Co się stanie po zakończeniu okresu próbnego?",
    answer:
      "Twoja przestrzeń robocza przechodzi na plan Free. Nic nie zostanie usunięte — projekty ponad limity planu Free będą dostępne tylko do odczytu, dopóki nie przejdziesz na wyższy plan albo ich nie zarchiwizujesz.",
  },
  {
    value: "invoices",
    question: "Czy wystawiacie faktury VAT?",
    answer:
      "Tak. Dodaj dane firmy i numer NIP w sekcji Rozliczenia, a pojawią się na każdej fakturze. Faktury są dostępne w formacie PDF i możemy je wysyłać na osobny adres e-mail do rozliczeń.",
  },
  {
    value: "security",
    question: "Gdzie przechowywane są moje dane?",
    answer:
      "Domyślnie w UE, zaszyfrowane zarówno w spoczynku, jak i podczas przesyłania. Przestrzenie robocze w planie Enterprise mogą wybrać region danych i używać własnych kluczy szyfrowania.",
  },
]

export default function AccordionFaq() {
  return (
    <div className="flex w-full max-w-xl flex-col gap-6">
      <div className="flex flex-col gap-1.5">
        <h3 className="text-heading-md text-foreground">
          Najczęściej zadawane pytania
        </h3>
        <p className="text-sm text-muted-foreground">
          Wszystko o planach, rozliczeniach i twoich danych.
        </p>
      </div>
      <Accordion>
        {questions.map((item) => (
          <AccordionItem key={item.value} value={item.value}>
            <AccordionTrigger>{item.question}</AccordionTrigger>
            <AccordionContent>{item.answer}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}

Wiele otwartych sekcji

Ustaw multiple, gdy trzeba porównywać sekcje albo pracować w kilku naraz — przy filtrach, ustawieniach czy pogrupowanych opcjach formularza. Tutaj dwie grupy filtrów są otwarte od początku.

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"

const filters = [
  {
    value: "status",
    label: "Status",
    options: ["Backlog", "W toku", "W przeglądzie", "Gotowe"],
  },
  {
    value: "priority",
    label: "Priorytet",
    options: ["Pilny", "Wysoki", "Średni", "Niski"],
  },
  {
    value: "team",
    label: "Zespół",
    options: ["Projektowanie", "Inżynieria", "Marketing"],
  },
]

export default function AccordionMultiple() {
  return (
    <Accordion
      multiple
      defaultValue={["status", "priority"]}
      className="w-full max-w-64"
    >
      {filters.map((filter) => (
        <AccordionItem key={filter.value} value={filter.value}>
          <AccordionTrigger>{filter.label}</AccordionTrigger>
          <AccordionContent>
            {/* pt-1 keeps the first checkbox's 24px target clear of the trigger. */}
            <FieldGroup className="gap-2.5 pt-1">
              {filter.options.map((option, index) => {
                const id = `${filter.value}-${index}`
                return (
                  <Field key={id} orientation="horizontal">
                    <Checkbox id={id} defaultChecked={index === 1} />
                    <FieldLabel htmlFor={id} className="font-normal">
                      {option}
                    </FieldLabel>
                  </Field>
                )
              })}
            </FieldGroup>
          </AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

Z ikonami i podsumowaniami

Wyzwalacze przyjmują dowolne elementy liniowe. Ikona na początku i jednowierszowe podsumowanie pozwalają ocenić, czy warto otworzyć sekcję — bez jej otwierania. Dodaj wcięcie panelu (className w AccordionContent stylizuje jego wewnętrzny kontener), żeby tekst panelu był wyrównany do tytułu.

import {
  KeyRoundIcon,
  MonitorSmartphoneIcon,
  ShieldCheckIcon,
} from "lucide-react"

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

const sections = [
  {
    value: "2fa",
    icon: ShieldCheckIcon,
    title: "Uwierzytelnianie dwuskładnikowe",
    summary: "Włączone · Aplikacja uwierzytelniająca",
    body: "Przy logowaniu na nowym urządzeniu poprosimy o kod z aplikacji uwierzytelniającej. Przechowuj kody odzyskiwania w bezpiecznym miejscu — jeśli zgubisz telefon, tylko one pozwolą ci odzyskać dostęp.",
  },
  {
    value: "passkeys",
    icon: KeyRoundIcon,
    title: "Klucze dostępu",
    summary: "2 klucze dostępu",
    body: "Klucze dostępu pozwalają logować się za pomocą Face ID, Touch ID lub klucza bezpieczeństwa zamiast hasła. Są odporne na phishing i przechowywane na twoich urządzeniach.",
  },
  {
    value: "sessions",
    icon: MonitorSmartphoneIcon,
    title: "Aktywne sesje",
    summary: "3 urządzenia",
    body: "MacBook Pro · Warszawa — bieżąca sesja. iPhone 16 · Warszawa — 2 godz. temu. Chrome w systemie Windows · Berlin — 3 dni temu.",
  },
]

export default function AccordionWithIcons() {
  return (
    <Accordion
      defaultValue={["2fa"]}
      className="w-full max-w-md rounded-xl border bg-card px-4 shadow-raised"
    >
      {sections.map((section) => (
        <AccordionItem key={section.value} value={section.value}>
          <AccordionTrigger>
            <span className="flex items-center gap-3">
              <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-surface text-muted-foreground">
                <section.icon className="size-4" aria-hidden="true" />
              </span>
              <span className="flex flex-col gap-0.5">
                <span>{section.title}</span>
                <span className="text-body-xs text-muted-foreground">
                  {section.summary}
                </span>
              </span>
            </span>
          </AccordionTrigger>
          <AccordionContent className="pl-11">{section.body}</AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}

Wyłączony element

Wyłącz element, który istnieje, ale jest niedostępny, i wyjaśnij powód w jego etykiecie. Wyzwalacz pozostaje widoczny, czytniki ekranu odczytują go jako wyłączony i nie da się go przełączyć.

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

export default function AccordionDisabled() {
  return (
    <Accordion className="w-full max-w-sm">
      <AccordionItem value="profile">
        <AccordionTrigger>Profil</AccordionTrigger>
        <AccordionContent>
          Twoje imię i nazwisko, zdjęcie oraz opis widzą wszyscy w przestrzeni roboczej.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="sso" disabled>
        <AccordionTrigger>Logowanie jednokrotne (Enterprise)</AccordionTrigger>
        <AccordionContent>
          Połącz z Okta, Entra ID lub dowolnym dostawcą SAML 2.0.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="notifications">
        <AccordionTrigger>Powiadomienia</AccordionTrigger>
        <AccordionContent>
          Wybierz, o jakiej aktywności chcesz dostawać e-maile lub powiadomienia push.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}

Tryb kontrolowany

Kontroluj value, aby otwierać sekcje z zewnątrz — na potrzeby przycisku Rozwiń wszystkie, linków prowadzących prosto do sekcji czy przywracania tego, co ktoś miał wcześniej otwarte. onValueChange otrzymuje nową tablicę wartości otwartych elementów.

Rozwinięte: 1 z 3

"use client"

import * as React from "react"

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"

const code =
  "rounded-md border bg-surface px-1 py-0.5 font-mono text-xs text-foreground"

const steps = [
  {
    value: "install",
    title: "Zainstaluj CLI",
    body: (
      <>
        Uruchom instalator raz na każdym komputerze — doda polecenie{" "}
        <code className={code}>prfct</code> do zmiennej PATH.
      </>
    ),
  },
  {
    value: "link",
    title: "Połącz projekt",
    body: (
      <>
        W katalogu repozytorium uruchom <code className={code}>prfct link</code> i wybierz przestrzeń roboczą, z którą chcesz się połączyć.
      </>
    ),
  },
  {
    value: "deploy",
    title: "Wdróż",
    body: "Wypchnij zmiany do gałęzi main. Każdy commit dostaje adres podglądu, a main trafia na produkcję.",
  },
]

export default function AccordionControlled() {
  const [value, setValue] = React.useState<string[]>(["install"])
  const allOpen = value.length === steps.length

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div className="flex items-center justify-between">
        <span className="text-sm text-muted-foreground">
          {`Rozwinięte: ${value.length} z ${steps.length}`}
        </span>
        <Button
          variant="ghost"
          size="xs"
          onClick={() => setValue(allOpen ? [] : steps.map((s) => s.value))}
        >
          {allOpen ? "Zwiń wszystkie" : "Rozwiń wszystkie"}
        </Button>
      </div>
      <Accordion multiple value={value} onValueChange={setValue}>
        {steps.map((step) => (
          <AccordionItem key={step.value} value={step.value}>
            <AccordionTrigger>{step.title}</AccordionTrigger>
            <AccordionContent>{step.body}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}
const [value, setValue] = React.useState<string[]>(["install"])

<Accordion multiple value={value} onValueChange={setValue}>
  …
</Accordion>

Ruch

Panele animują swoją rzeczywistą wysokość z czasem trwania base i krzywą przejścia standard, a ich treść płynnie się pojawia w tym samym rytmie; strzałka obraca się synchronicznie. Nic nie jest skalowane ani przycinane, więc tekst nigdy się nie rozmywa. Przy prefers-reduced-motion sekcje otwierają się natychmiast.

Wytyczne

Kiedy używać

  • Aby skrócić długie strony, których sekcje dotyczą tylko części czytelników: FAQ, szczegóły produktu, informacje o wersji.
  • Aby pogrupować powiązane ustawienia lub filtry, do których wraca się wybiórczo.
  • Na małych ekranach — żeby treść drugorzędna była w zasięgu jednego dotknięcia, zamiast zajmować miejsce, które trzeba przewinąć.

Kiedy nie używać

  • Do treści, której potrzebuje każdy. Ukrycie jej kosztuje dodatkowe kliknięcie i utrudnia jej znalezienie — po prostu ją pokaż.
  • Do pojedynczego obszaru, który można pokazać lub ukryć — użyj komponentu Collapsible.
  • Do przełączania między widokami tych samych danych — użyj komponentu Tabs.
  • Do nawigacji — użyj komponentu Sidebar lub Navigation Menu.

Pisz wyzwalacze łatwe do przejrzenia

Wyzwalacze są nagłówkami. Pisz je krótko, zaczynaj od słowa kluczowego i dbaj, żeby ich zakresy się nie pokrywały — wtedy od razu widać, pod którym kryje się odpowiedź. W FAQ formułuj je jako pytania, które zadałby czytelnik.

DobrzeKrótkie, konkretne nagłówki, które zapowiadają zawartość sekcji.

ŹleOgólnikowe etykiety zmuszają do otwierania każdej sekcji, żeby cokolwiek znaleźć.

Zachowaj płaską strukturę

Nie zagnieżdżaj akordeonów. Drugi poziom ukrywa treść za dwoma kliknięciami i utrudnia śledzenie, co jest otwarte, a co zamknięte. Jeśli treść wymaga hierarchii, daj jej osobną stronę.

Nie ukrywaj tego, co kluczowe dla zadania

Ceny, błędy, pola wymagane i wszystko, co może zmienić decyzję, muszą pozostać widoczne. Akordeon służy do treści pomocniczych — jeśli po zwinięciu sekcji ktoś mógłby przeoczyć coś ważnego, ta treść nie powinna się w nim znaleźć.

Dostępność

Każdy wyzwalacz to <button> wewnątrz <h3>, z atrybutem aria-expanded oraz — gdy sekcja jest otwarta — aria-controls, który wskazuje jego panel. Panele mają role="region", a ich etykietą jest wyzwalacz, więc czytnik ekranu informuje, w której sekcji znajduje się użytkownik.

KlawiszDziałanie
Tab
Przenosi fokus na następny wyzwalacz albo do elementów otwartego panelu, które mogą przyjąć fokus.
ShiftTab
Przenosi fokus na poprzedni wyzwalacz lub poprzedni element, który może przyjąć fokus.
EnterSpacja
Rozwija lub zwija sekcję, na której jest fokus.
  • Bez nawigacji strzałkami. Zgodnie z aktualizacją WAI-ARIA Authoring Practices z 2025 roku nagłówki akordeonu są zwykłymi elementami kolejności tabulacji; komponent nie przechwytuje klawiszy strzałek.
  • Poziom nagłówka. Wyzwalacze są umieszczone w <h3>. Jeśli to zaburza strukturę nagłówków twojej strony, zmień render elementu AccordionPrimitive.Header w swojej kopii komponentu (na przykład render={<h2 />}).
  • Wyszukiwanie na stronie. Ustaw hiddenUntilFound na elemencie głównym (lub na panelu), a wbudowane wyszukiwanie przeglądarki przeszuka zamknięte sekcje i otworzy tę, w której znajdzie dopasowanie.
  • Wyłączone elementy pozostają w kolejności odczytu i są ogłaszane jako wyłączone, więc użytkownik wie, że taka sekcja istnieje.

Dokumentacja API

Accordion

Grupuje elementy i zarządza tym, które z nich są otwarte. Renderuje <div>. Przyjmuje wszystkie propsy Accordion.Root z Base UI.

PropTypDomyślnie
defaultValue

Wartości elementów otwartych przy pierwszym renderowaniu (tryb niekontrolowany).

any[]Brak wartości domyślnej
value

Wartości otwartych elementów (tryb kontrolowany). Używaj razem z onValueChange.

any[]Brak wartości domyślnej
onValueChange

Wywoływana z nową tablicą wartości otwartych elementów, gdy któryś element zostanie przełączony.

(value: any[], details) => voidBrak wartości domyślnej
multiple

Pozwala otworzyć więcej niż jeden element jednocześnie.

booleanfalse
disabled

Wyłącza wszystkie elementy.

booleanfalse
hiddenUntilFound

Zostawia zamknięte panele w DOM z hidden="until-found", żeby wyszukiwanie na stronie mogło je odsłonić.

booleanfalse
keepMounted

Zostawia zamknięte panele w DOM. Ignorowany, gdy ustawiono hiddenUntilFound.

booleanfalse

AccordionItem

Jeden nagłówek i jego panel. Renderuje <div>.

PropTypDomyślnie
value

Identyfikuje element w value i defaultValue. Jeśli go pominiesz, zostanie wygenerowany — ustawiaj go zawsze, gdy sterujesz akordeonem.

anyBrak wartości domyślnej
disabled

Uniemożliwia przełączanie elementu.

booleanfalse
onOpenChange

Wywoływana, gdy ten element się otwiera lub zamyka.

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

AccordionTrigger

Przycisk, który przełącza swój element, opakowany w <h3>. Zawiera obracającą się strzałkę. Przyjmuje wszystkie propsy Accordion.Trigger z Base UI.

AccordionContent

Zwijany panel. className to ciąg znaków, który styluje treść z paddingiem wewnątrz panelu (padding, typografia), a zewnętrzny panel odpowiada za animację wysokości. Przyjmuje wszystkie pozostałe propsy Accordion.Panel z Base UI, w tym hiddenUntilFound i keepMounted.