shadcn ui : Layout Primitives
Four shadcn primitives ship as pure layout helpers. They hold no business state and expose no independent decision logic. They differ only in which layout problem they solve : drag-to-resize panes, custom-styled scroll, content divider, fixed width:height ratio. Pick by the layout problem and compose. This skill covers all four because none of them is large enough to warrant a dedicated skill, and the composition rules are isolated per primitive.
Quick Reference
The four primitives
| Primitive | Solves | Library | Subcomponents | "use client" in v4 source |
|---|---|---|---|---|
Resizable |
Drag-to-resize split panes | react-resizable-panels v4 |
ResizablePanelGroup, ResizablePanel, ResizableHandle |
YES |
ScrollArea |
Custom-styled scrollable region | Radix ScrollArea |
ScrollArea, ScrollBar |
YES |
Separator |
Horizontal or vertical divider | Radix Separator |
Separator |
YES |
AspectRatio |
Lock container to width:height ratio | Radix AspectRatio |
AspectRatio |
YES |
Decision tree : Which primitive?
Q. What layout problem do I have?
Two or more regions, user drags a handle to resize them -> Resizable
A scroll region that needs styled scrollbars (or a thin,
always-visible scrollbar that matches the theme) -> ScrollArea
Visual line dividing two content blocks (in a list, in
a stack, between toolbar groups) -> Separator
Element whose visible height must be a fixed fraction of
its width (video, image, iframe, map embed, canvas) -> AspectRatio
ALWAYS pick the primitive from the layout problem. NEVER pick AspectRatio for a generic flex / grid layout problem ; it freezes the ratio and absolutely-positions the child, which fights flex / grid layouts.
"use client" invariant
The v4 registry source for ALL FOUR primitives starts with "use client". This is true even for Separator and AspectRatio, which appear stateless. The directive is in the source because the file uses Radix primitives whose internals call React client hooks. NEVER strip "use client" from components/ui/{resizable,scroll-area,separator,aspect-ratio}.tsx even if your linter suggests it is unused. Stripping it makes the file a Server Component, and Radix throws a "client hooks in server component" error at hydration.
Five invariants
- ALWAYS wrap every
ResizablePanelinside aResizablePanelGroup. NEVER renderResizablePanelat the root of a layout ; react-resizable-panels throws "Panel components must be rendered within a PanelGroup container". - ALWAYS give
ScrollAreaan explicit height (ormax-height) via a Tailwind class such ash-[200px],h-72, ormax-h-screen. NEVER expect ScrollArea to scroll without a constrained height ; it just expands to its content. - ALWAYS pass
orientation="vertical"on Separator inside a horizontal flex / row. NEVER rely on the defaultorientation="horizontal"for a vertical divider ; the default renders anh-px w-fulldiv which collapses to zero inside a row. - ALWAYS give an AspectRatio child
className="absolute inset-0 ..."if the child is positioned absolutely (or useobject-cover/object-containon an<img>/<video>to fill). NEVER leave a non-filling child inside AspectRatio ; the ratio container will appear empty. - ALWAYS pass distinct
autoSaveIdvalues to nestedResizablePanelGroupinstances when both groups should persist layout. NEVER share anautoSaveIdacross two groups ; localStorage state collides and one group resets the other on mount.
Resizable : drag-to-resize split panes
Wraps react-resizable-panels v4 (peer dependency installed automatically by shadcn add resizable). The library handles all drag math, keyboard resize, and ARIA wiring. shadcn adds Tailwind styling and a withHandle grip prop on the handle.
Subcomponents
ResizablePanelGroup, ResizablePanel, ResizableHandle. Imports :
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable"
PanelGroup props (v4, post-rename)
| Prop | Type | Notes |
|---|---|---|
orientation |
"horizontal" | "vertical" |
Layout axis. v4 rename : was direction in v3. ALWAYS use orientation in v4. |
onLayoutChange |
(sizes: number[]) => void |
Fires after layout changes. v4 rename : was onLayout in v3. |
autoSaveId |
string |
localStorage key for layout persistence. Distinct per group. |
id |
string |
Stable id for SSR layout-restoration when autoSaveId is set. |
keyboardResizeBy |
number | null |
Keyboard arrow resize step in pixels (default 10, null disables). |
Panel props
| Prop | Type | Notes |
|---|---|---|
defaultSize |
number |
Initial percent (0-100). ALL panels' defaultSize should sum to ~100. |
minSize |
number |
Minimum percent (default 0). Drag clamps to this floor. |
maxSize |
number |
Maximum percent (default 100). Drag clamps to this ceiling. |
collapsible |
boolean |
When true, the panel can collapse below minSize to collapsedSize. |
collapsedSize |
number |
Percent the panel collapses to (default 0). |
onCollapse |
() => void |
Fires when panel collapses. |
onExpand |
() => void |
Fires when panel expands from collapsed. |
order |
number |
Stable order for SSR / conditional rendering. |
id |
string |
Stable id for SSR / conditional rendering. |
Handle props
| Prop | Type | Notes |
|---|---|---|
withHandle |
boolean |
Render the visible grip icon (GripVerticalIcon). Default false (invisible drag line). |
disabled |
boolean |
Disable drag interaction. |
Minimal pattern : horizontal split
"use client"
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable"
<ResizablePanelGroup
orientation="horizontal"
className="min-h-[400px] max-w-md rounded-lg border"
>
<ResizablePanel defaultSize={50} minSize={20}>
<div className="flex h-full items-center justify-center p-6">Left</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={20}>
<div className="flex h-full items-center justify-center p-6">Right</div>
</ResizablePanel>
</ResizablePanelGroup>
The Group itself MUST have a bounded height (the example uses min-h-[400px]). Without a height bound, the group is 0 tall and the handle is unreachable.
ScrollArea : custom-styled scrollable region
Wraps Radix ScrollArea. The shadcn ScrollArea composes ScrollAreaPrimitive.Root + Viewport + a default vertical ScrollBar + Corner in its source. You only need to add a ScrollBar element manually for horizontal scroll or to override the vertical default.
Subcomponents
ScrollArea, ScrollBar. Imports :
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
ScrollArea props
Forwarded directly to ScrollAreaPrimitive.Root :
| Prop | Type | Notes |
|---|---|---|
type |
"auto" | "always" | "scroll" | "hover" |
When scrollbars appear (default "hover"). |
scrollHideDelay |
number |
ms to keep scrollbar visible after scroll stops (default 600). Only when type is "scroll" or "hover". |
dir |
"ltr" | "rtl" |
Reading direction. Inherited from <DirectionProvider> by default. |
ScrollBar props
| Prop | Type | Notes |
|---|---|---|
orientation |
"vertical" | "horizontal" |
Default "vertical". The shadcn ScrollArea already renders a vertical one ; add a horizontal ScrollBar as a child for horizontal scroll. |
Minimal pattern : vertical scroll (default)
import { ScrollArea } from "@/components/ui/scroll-area"
<ScrollArea className="h-72 w-48 rounded-md border">
<div className="p-4">
{tags.map((tag) => <div key={tag} className="text-sm">{tag}</div>)}
</div>
</ScrollArea>
A vertical scrollbar appears automatically. NEVER add <ScrollBar /> manually for the vertical case ; the shadcn ScrollArea already renders one in its source.
Adding a horizontal ScrollBar
<ScrollArea className="w-96 whitespace-nowrap rounded-md border">
<div className="flex w-max gap-4 p-4">{children}</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
The ScrollBar orientation="horizontal" is required for horizontal axis. The shadcn source only renders the vertical ScrollBar internally.
Separator : horizontal or vertical divider
Wraps Radix Separator. Renders a styled div (bg-border) sized to h-px w-full (horizontal) or h-full w-px (vertical) via data-orientation. Defaults to decorative={true} (which renders as role="none", omitted from the accessibility tree).
Subcomponent
Separator. Import :
import { Separator } from "@/components/ui/separator"
Props
| Prop | Type | Notes |
|---|---|---|
orientation |
"horizontal" | "vertical" |
Default "horizontal". Choose by the axis of the line, not the parent layout. |
decorative |
boolean |
Default true. When true, omitted from a11y tree (role="none"). When false, exposed as role="separator" and announced. |
Minimal pattern : horizontal (in a stack)
import { Separator } from "@/components/ui/separator"
<div>
<h4>Radix Primitives</h4>
<p className="text-sm text-muted-foreground">An open-source UI library.</p>
<Separator className="my-4" />
<div className="flex gap-4">
<span>Blog</span>
<Separator orientation="vertical" />
<span>Docs</span>
<Separator orientation="vertical" />
<span>Source</span>
</div>
</div>
The horizontal <Separator className="my-4" /> divides two stack blocks. The vertical <Separator orientation="vertical" /> between inline labels MUST be inside a parent with a bounded height (typically flex items which take the row's height automatically).
decorative rule
- ALWAYS leave
decorative={true}(the default) when the visual line is purely cosmetic (between two visually-related blocks, between toolbar groups). - ALWAYS pass
decorative={false}when the separator marks a semantic break (e.g., between distinct list groups in a screen-reader navigation). Thenrole="separator"is announced.
AspectRatio : lock container to a width:height ratio
Wraps Radix AspectRatio. Computes a padding-bottom hack so the box height equals (1 / ratio) * width. Children are positioned absolutely inside the box. Use for video, image, iframe, map embed, canvas.
Subcomponent
AspectRatio. Import :
import { AspectRatio } from "@/components/ui/aspect-ratio"
Props
| Prop | Type | Notes |
|---|---|---|
ratio |
number |
Width / height. Common : 16 / 9 (video), 1 / 1 (square avatar), 9 / 16 (portrait), 4 / 3 (legacy media), 21 / 9 (cinema). |
Minimal pattern : 16/9 image
import Image from "next/image"
import { AspectRatio } from "@/components/ui/aspect-ratio"
<div className="w-[450px]">
<AspectRatio ratio={16 / 9} className="bg-muted rounded-md">
<Image src="/photo.jpg" alt="" fill className="rounded-md object-cover" />
</AspectRatio>
</div>
The outer wrapper (w-[450px]) sets the width ; AspectRatio derives the height. The Next.js <Image> with fill fills the absolutely-positioned slot. For a plain <img> or <video> use className="size-full object-cover" on the child.
Children pattern
AspectRatio's internal box uses position: relative + a padding-bottom spacer. Children are stacked in the same coordinate space. ALWAYS make a positioned child fill the box, either with :
fill(Next.js<Image>), orclassName="size-full object-cover"(plain<img>,<video>), orclassName="absolute inset-0"(for a generic positioned wrapper).
Without one of these, the child sits at its natural size in the top-left and the ratio container appears empty.
When to use which
| Want | Primitive |
|---|---|
| Two-panel editor / preview / sidebar that the user can drag-resize | Resizable |
| Long list constrained to a fixed height with a styled scrollbar | ScrollArea |
| Theme-aware divider between blocks in a stack | Separator (horizontal) |
| Theme-aware divider between inline items in a row | Separator (vertical) |
| Video / iframe / image that stays 16:9 (or any ratio) regardless of width | AspectRatio |
| Square thumbnail / avatar with consistent height in a grid | AspectRatio (1/1) |
NEVER reach for Resizable when CSS resize: both on a textarea is enough ; Resizable is for multi-panel application chrome, not for single-element resize affordances.
NEVER reach for AspectRatio for an <img> that already has intrinsic dimensions and is rendered with width / height HTML attributes ; the browser holds the ratio for you. AspectRatio is for cases where the container needs the ratio (positioned children, background images, iframes, videos with unknown intrinsic size).
Companion Skills
shadcn-impl-rsc-vs-client-boundariesfor the full"use client"decision rules across the catalog.shadcn-syntax-sidebarfor application chrome that pairs with Resizable for sidebar+content split layouts.shadcn-syntax-tablefor the inner content of a horizontally-scrolled ScrollArea (responsive wide-table pattern).shadcn-errors-styling-conflictsforcn()and Tailwind merge rules that apply to every primitive'sclassNameprop.
References
references/methods.md: per-primitive composition + full prop signatures verbatim from the v4 registry source.references/examples.md: six working examples (horizontal Resizable, vertical Resizable + nested groups, ScrollArea with horizontal ScrollBar, Separator orientation contrast, AspectRatio 16/9 video, AspectRatio 1/1 avatar grid).references/anti-patterns.md: five canonical failures (Panel without PanelGroup, ScrollArea without height, Separator without orientation, AspectRatio child without inset-0, nested Resizable groups sharing autoSaveId).