Resizable
Panele, których rozmiar można zmieniać przeciąganiem lub z klawiatury — do układów, których proporcje każdy ustawia po swojemu.
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
function Label({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full items-center justify-center p-6">
<span className="text-sm font-medium text-muted-foreground">
{children}
</span>
</div>
)
}
export default function ResizableDemo() {
return (
<div className="h-48 w-full max-w-md">
<ResizablePanelGroup
orientation="horizontal"
className="rounded-xl border bg-card shadow-raised"
>
<ResizablePanel defaultSize="50%" minSize="20%">
<Label>Pasek boczny</Label>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="50%" minSize="20%">
<ResizablePanelGroup orientation="vertical">
<ResizablePanel defaultSize="30%" minSize="20%">
<Label>Nagłówek</Label>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="70%" minSize="20%">
<Label>Treść</Label>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}Anatomia
- 1GrupaUkłada panele w rzędzie albo w kolumnie i dzieli między nie miejsce.
- 2PanelJeden obszar. Jego rozmiar to część grupy, opcjonalnie z ograniczeniami.
- 3UchwytLinia między dwoma panelami. Przeciągnij ją albo ustaw na niej fokus i użyj strzałek.
Instalacja
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/resizable.jsonUżycie
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"<div className="h-80">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel defaultSize="30%" minSize="20%">Sidebar</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="70%" minSize="30%">Content</ResizablePanel>
</ResizablePanelGroup>
</div>Grupa zawsze wypełnia element nadrzędny — rozmiar ustalaj na nim, a nie na grupie. Rozmiary przyjmują ciągi znaków z jednostkami ("30%", "240px", "16rem"); sama liczba oznacza piksele, a ciąg znaków bez jednostki — procenty.
direction to teraz orientation, PanelResizeHandle to Separator (opakowany jako ResizableHandle), a defaultSize={30} oznacza 30 pikseli — pisz defaultSize="30%".Przykłady
Układ pionowy
Ułóż panele jeden nad drugim przez orientation="vertical" — zapytanie nad wynikami, edytor nad konsolą.
select * from deployments where status = 'error'import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
export default function ResizableVertical() {
return (
<div className="h-64 w-full max-w-sm">
<ResizablePanelGroup
orientation="vertical"
className="rounded-xl border bg-card shadow-raised"
>
<ResizablePanel defaultSize="35%" minSize="20%">
<div className="flex h-full flex-col gap-1 p-4">
<span className="text-sm font-semibold text-foreground">Zapytanie</span>
<code className="font-mono text-xs text-muted-foreground">
select * from deployments where status = 'error'
</code>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="65%" minSize="30%">
<div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
3 wiersze · 12 ms
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}Z uchwytem
withHandle rysuje na separatorze mały uchwyt. Używaj go, gdy separator łatwo przeoczyć — przy wąskich panelach, na powierzchniach o niskim kontraście, na urządzeniach dotykowych.
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
export default function ResizableWithHandle() {
return (
<div className="h-48 w-full max-w-md">
<ResizablePanelGroup
orientation="horizontal"
className="rounded-xl border bg-card shadow-raised"
>
<ResizablePanel defaultSize="40%" minSize="25%">
<div className="flex h-full items-center justify-center p-6 text-sm font-medium text-muted-foreground">
Przed
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="60%" minSize="25%">
<div className="flex h-full items-center justify-center p-6 text-sm font-medium text-muted-foreground">
Po
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}Układ zagnieżdżony
Grupy można zagnieżdżać, budując prawdziwe układy aplikacji. Tutaj eksplorator plików ma collapsible oraz minSize i maxSize, więc po przeciągnięciu poniżej minimum od razu się zwija; terminal też da się zwinąć. Ustaw fokus na separatorze i naciśnij Enter, aby zwinąć lub przywrócić panel przed nim.
1import { cva } from "class-variance-authority"23export const buttonVariants = cva([4 "inline-flex items-center",5])
$ pnpm dev
✓ Ready in 594msimport {
ChevronDownIcon,
FileCodeIcon,
FolderIcon,
TerminalIcon,
} from "lucide-react"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
const files = ["button.tsx", "dialog.tsx", "tokens.css", "utils.ts"]
const code = [
["import", " { cva } ", "from", " \"class-variance-authority\""],
[""],
["export", " const ", "buttonVariants", " = cva(["],
[" ", "\"inline-flex items-center\"", ","],
["])"],
]
export default function ResizableIde() {
return (
<div className="h-80 w-full max-w-2xl">
<ResizablePanelGroup
orientation="horizontal"
className="rounded-xl border bg-card shadow-raised"
>
<ResizablePanel
defaultSize="26%"
minSize="18%"
maxSize="40%"
collapsible
collapsedSize="0%"
>
<div className="flex h-full flex-col gap-1 bg-surface p-2 text-body-xs">
<span className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
<ChevronDownIcon className="size-3.5" aria-hidden="true" />
<FolderIcon className="size-3.5" aria-hidden="true" />
components
</span>
{files.map((file, index) => (
<span
key={file}
className={
index === 0
? "flex items-center gap-2 rounded-md bg-accent px-2 py-1 pl-7 text-foreground"
: "flex items-center gap-2 rounded-md px-2 py-1 pl-7 text-muted-foreground"
}
>
<FileCodeIcon
className="size-3.5 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{file}</span>
</span>
))}
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="74%">
<ResizablePanelGroup orientation="vertical">
<ResizablePanel defaultSize="68%" minSize="30%">
<div className="flex h-full flex-col">
<div className="flex h-9 items-center border-b px-3 font-mono text-xs text-foreground">
button.tsx
</div>
{/* A narrow pane scrolls its code, as an editor would. */}
<pre
tabIndex={0}
aria-label="button.tsx"
className="flex-1 scrollbar-thin overflow-auto p-3 font-mono text-xs leading-5 focus-ring-inset"
>
{code.map((line, i) => (
<div key={i} className="flex gap-4">
<span className="w-4 text-right text-muted-foreground select-none">
{i + 1}
</span>
<span>
{line.map((token, j) => (
<span
key={j}
className={
token.trim().startsWith('"')
? "text-green-11"
: [
"import",
"from",
"export",
"const",
].includes(token)
? "text-violet-11"
: token === "buttonVariants"
? "text-brand-11"
: "text-foreground"
}
>
{token}
</span>
))}
</span>
</div>
))}
</pre>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="32%" minSize="15%" collapsible>
<div className="flex h-full flex-col bg-surface">
<div className="flex h-8 items-center gap-1.5 border-b px-3 text-xs font-medium text-muted-foreground">
<TerminalIcon className="size-3.5" aria-hidden="true" />
Terminal
</div>
<pre className="p-3 font-mono text-xs leading-5 text-muted-foreground">
$ pnpm dev{"\n"}
<span className="text-green-11">✓</span> Ready in 594ms
</pre>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}Zapamiętywanie układu
useDefaultLayout przywraca zapisany układ przy montowaniu i zapisuje go po każdej zmianie rozmiaru. Nadaj każdemu panelowi id, aby zapisane rozmiary trafiały do właściwych paneli. localStorage istnieje tylko w przeglądarce, dlatego ten podgląd renderuje na serwerze układ domyślny, a po hydratacji przełącza się na zapisany.
"use client"
import * as React from "react"
import { useDefaultLayout, type GroupProps } from "react-resizable-panels"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
function Layout(props: Pick<GroupProps, "defaultLayout" | "onLayoutChanged">) {
return (
<ResizablePanelGroup
orientation="horizontal"
className="rounded-xl border bg-card shadow-raised"
{...props}
>
<ResizablePanel id="list" defaultSize="40%" minSize="25%">
<div className="flex h-full items-center justify-center p-6 text-sm font-medium text-muted-foreground">
Lista
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="detail" defaultSize="60%" minSize="30%">
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<span className="text-sm font-medium text-muted-foreground">
Szczegóły
</span>
<span className="text-xs text-muted-foreground">
Zmień rozmiar, a potem odśwież stronę.
</span>
</div>
</ResizablePanel>
</ResizablePanelGroup>
)
}
function PersistedLayout() {
// Restores the last layout from localStorage and saves it after each resize.
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "prfct-docs-persisted-layout",
storage: localStorage,
})
return (
<Layout defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged} />
)
}
const subscribe = () => () => {}
export default function ResizablePersisted() {
// localStorage only exists in the browser: render the default layout on the
// server and during hydration, then switch to the saved one.
const hydrated = React.useSyncExternalStore(
subscribe,
() => true,
() => false
)
return (
<div className="h-40 w-full max-w-md">
{hydrated ? <PersistedLayout /> : <Layout />}
</div>
)
}W aplikacji Next.js renderowanej na serwerze lepiej zapisywać układ w pliku cookie: serwer może go odczytać, więc już pierwsze wyrenderowanie strony uwzględnia zapisane rozmiary i nic się nie przesuwa.
import { cookies } from "next/headers"
import { InboxPanels } from "./inbox-panels"
export default async function InboxLayout() {
const saved = (await cookies()).get("layout:inbox")?.value
return <InboxPanels defaultLayout={saved ? JSON.parse(saved) : undefined} />
}"use client"
import type { Layout } from "react-resizable-panels"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
export function InboxPanels({ defaultLayout }: { defaultLayout?: Layout }) {
return (
<ResizablePanelGroup
defaultLayout={defaultLayout}
onLayoutChanged={(layout) => {
document.cookie = `layout:inbox=${JSON.stringify(layout)}; path=/; max-age=31536000`
}}
>
<ResizablePanel id="list" defaultSize="40%" minSize="25%">…</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" defaultSize="60%" minSize="30%">…</ResizablePanel>
</ResizablePanelGroup>
)
}Wytyczne
Kiedy używać
- W narzędziach, w których spędza się długie sesje i ma się wyraźne preferencje co do przestrzeni: w edytorach, poczcie, menedżerach plików, pulpitach z panelami bocznymi.
- Gdy dwa widoki rywalizują o miejsce, a właściwy podział zależy od zadania.
Kiedy nie używać
- Na stronach marketingowych i treściowych, gdzie układ powinien być skomponowany z myślą o czytelniku.
- Na małych ekranach. Poniżej breakpointu
mdzamiast dzielonych paneli przejdź na jedną kolumnę, zakładki lub panel Sheet. - Do pokazywania i ukrywania całego panelu — przycisk przełączający jest czytelniejszy niż przeciąganie do zera.
Ustaw limity
Zawsze nadawaj panelom minSize, aby treści nie dało się zgnieść do bezużyteczności, oraz maxSize, gdy jedna strona musi pozostać widoczna. Oznacz panel jako collapsible, gdy całkowite ukrycie go jest uzasadnionym wyborem.
Zapamiętuj wybór użytkownika
Układ dopasowany przez użytkownika to jego preferencja. Zapisz go przez useDefaultLayout (albo we własnym magazynie danych przez onLayoutChanged), aby przetrwał przeładowanie strony.
Dostępność
Każdy separator to element role="separator", który może otrzymać fokus. Atrybuty aria-valuenow, aria-valuemin i aria-valuemax opisują rozmiar panelu przed nim, natomiast aria-orientation wskazuje kierunek prostopadły do grupy (grupa pozioma ma separatory pionowe).
| Klawisz | Działanie |
|---|---|
Tab | Przenosi fokus na następny separator. Podczas fokusu separator przyjmuje kolor marki i pokazuje pierścień fokusu. |
←→ | Zmienia rozmiar w grupie poziomej o 5%. |
↑↓ | Zmienia rozmiar w grupie pionowej o 5%. |
Home | Zmniejsza panel przed separatorem do minimum. |
End | Powiększa panel przed separatorem do maksimum. |
Enter | Zwija panel przed separatorem lub go przywraca — o ile ten panel da się zwinąć. |
F6ShiftF6 | Przenosi fokus na następny lub poprzedni separator w tej samej grupie. |
- Dwukrotne kliknięcie separatora przywraca domyślne rozmiary sąsiednich paneli (wyłączysz to przez
disableDoubleClick). - Obszar kliknięcia. Separatory mają 1 px szerokości, ale ich obszar kliknięcia jest większy — biblioteka wymusza minimalny rozmiar celu, większy przy mniej precyzyjnych wskaźnikach, np. przy dotyku.
- Nazwij panele. Nadaj panelom nagłówki lub
aria-label, aby użytkownik czytnika ekranu wiedział, jakiemu panelowi zmienia rozmiar.
Dokumentacja API
ResizablePanelGroup
Kontener, który rozmieszcza panele i separatory. Renderuje <div> wypełniający element nadrzędny. Opakowuje Group z react-resizable-panels v4.
ResizablePanel
Obszar o zmiennym rozmiarze. className trafia do wewnętrznego <div>, więc klasy układu nie kolidują z rozmiarem panelu wyznaczanym przez flex. Opakowuje Panel.
ResizableHandle
Separator między dwoma panelami. Opakowuje Separator.
Separator udostępnia swój stan w atrybucie data-separator: inactive, hover, active (przeciąganie), focus lub disabled — prfct koloruje go skalą marki po najechaniu, podczas przeciągania i przy fokusie.