Panel resizables
Five resizable splits whose panes are labeled cards with a small header: a plain header, a titled header with subtitle, a header with a badge, a header with a toolbar and a header styled as a tab. Each is self-contained (no panel library), drags with the pointer, is keyboard-adjustable via a role=separator handle, and is sized.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/resizable-panelDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/resizable-panel.tsx"use client"
import * as React from "react"
import { Minus, Square, X } from "lucide-react"
import { cn } from "@/lib/utils"
/* Panel resizable family: 5 self-contained split panels (NO panel library, NO
radix). The theme idea: the panes are labelled cards with a small title bar. Each
export holds an internal percentage (pct) state; the separator is dragged with
the pointer and adjusted with the arrow keys. Deterministic (no Date.now /
Math.random). The separator uses role="separator" + aria-orientation +
aria-valuenow/min/max, tabIndex=0. Color comes ONLY from semantic tokens; alpha
via color-mix or the Tailwind /NN. There is no motion in this family. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Orientation = "horizontal" | "vertical"
const height: Record<StyledSize, string> = {
sm: "h-40",
md: "h-56",
lg: "h-72",
xl: "h-96",
}
const MIN = 12
const MAX = 88
const clamp = (n: number) => Math.min(MAX, Math.max(MIN, n))
const focusRing =
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
/* Ortak bolme mantigi: pct state + pointer/klavye tutamac handler'lari.
horizontal = sol/sag panel, dikey ayirici (yatay suruklenir).
vertical = ust/alt panel, yatay ayirici (dikey suruklenir). */
function useResizable(orientation: Orientation, initial = 50) {
const [pct, setPct] = React.useState(initial)
const containerRef = React.useRef<HTMLDivElement>(null)
const [dragging, setDragging] = React.useState(false)
const applyFromPoint = React.useCallback(
(clientX: number, clientY: number) => {
const el = containerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const raw =
orientation === "horizontal"
? ((clientX - rect.left) / rect.width) * 100
: ((clientY - rect.top) / rect.height) * 100
setPct(clamp(raw))
},
[orientation]
)
const onPointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
e.currentTarget.setPointerCapture(e.pointerId)
setDragging(true)
},
[]
)
const onPointerMove = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
applyFromPoint(e.clientX, e.clientY)
},
[applyFromPoint]
)
const onPointerUp = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId))
e.currentTarget.releasePointerCapture(e.pointerId)
setDragging(false)
},
[]
)
const onKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const dec = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp"
const inc = orientation === "horizontal" ? "ArrowRight" : "ArrowDown"
if (e.key === dec) {
e.preventDefault()
setPct((p) => clamp(p - (e.shiftKey ? 10 : 2)))
} else if (e.key === inc) {
e.preventDefault()
setPct((p) => clamp(p + (e.shiftKey ? 10 : 2)))
} else if (e.key === "Home") {
e.preventDefault()
setPct(MIN)
} else if (e.key === "End") {
e.preventDefault()
setPct(MAX)
}
},
[orientation]
)
const separatorProps = {
role: "separator" as const,
"aria-orientation": (orientation === "horizontal" ? "vertical" : "horizontal") as
| "vertical"
| "horizontal",
"aria-valuenow": Math.round(pct),
"aria-valuemin": MIN,
"aria-valuemax": MAX,
tabIndex: 0,
onPointerDown,
onPointerMove,
onPointerUp,
onKeyDown,
}
return { pct, containerRef, dragging, separatorProps }
}
interface ResizableProps {
className?: string
size?: StyledSize
start?: React.ReactNode
end?: React.ReactNode
}
const shell =
"flex w-full select-none overflow-hidden rounded-xl border border-border"
const dividerBase =
"relative h-full w-px shrink-0 cursor-col-resize transition-colors after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']"
/* A titled card pane: a title bar on top, the body below. The header differs in
every variant. */
function CardPane({
header,
label,
tone = "bg-surface-2",
}: {
header: React.ReactNode
label: string
tone?: string
}) {
return (
<div className={cn("flex h-full w-full flex-col overflow-hidden", tone)}>
{header}
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
{label}
</div>
</div>
)
}
const headerBar =
"flex h-9 shrink-0 items-center gap-2 border-b border-border bg-[color-mix(in_oklab,var(--color-background)_70%,transparent)] px-3"
/* HeaderResizable: her bolmede sade baslik cubugu (baslik metni). */
export function HeaderResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, separatorProps } = useResizable("horizontal")
const header = (title: string) => (
<div className={headerBar}>
<span className="text-xs font-semibold text-foreground">{title}</span>
</div>
)
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, height[size], className)}>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <CardPane header={header("Explorer")} label="Panel bir" tone="bg-surface-2" />}
</div>
<div {...separatorProps} className={cn(dividerBase, focusRing, "bg-border hover:bg-primary/60")} />
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <CardPane header={header("Preview")} label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* TitledResizable: baslik cubugunda baslik + soluk alt aciklama. */
export function TitledResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, separatorProps } = useResizable("horizontal")
const header = (title: string, sub: string) => (
<div className={cn(headerBar, "h-11 flex-col items-start justify-center gap-0")}>
<span className="text-xs font-semibold leading-tight text-foreground">{title}</span>
<span className="text-[0.65rem] leading-tight text-muted-foreground">{sub}</span>
</div>
)
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, height[size], className)}>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <CardPane header={header("Source", "main.tsx")} label="Panel bir" tone="bg-surface-2" />}
</div>
<div {...separatorProps} className={cn(dividerBase, focusRing, "bg-border hover:bg-primary/60")} />
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <CardPane header={header("Output", "console")} label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* BadgeResizable: baslik cubugunda baslik + sagda kucuk rozet. */
export function BadgeResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, separatorProps } = useResizable("horizontal")
const header = (title: string, badge: string) => (
<div className={cn(headerBar, "justify-between")}>
<span className="text-xs font-semibold text-foreground">{title}</span>
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[0.65rem] font-medium text-primary">
{badge}
</span>
</div>
)
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, height[size], className)}>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <CardPane header={header("Files", "12")} label="Panel bir" tone="bg-surface-2" />}
</div>
<div {...separatorProps} className={cn(dividerBase, focusRing, "bg-border hover:bg-primary/60")} />
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <CardPane header={header("Diff", "new")} label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* ToolbarResizable: baslik cubugunda baslik + sagda kucuk ikon dugmeleri. */
export function ToolbarResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, separatorProps } = useResizable("horizontal")
const btn =
"inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)] hover:text-foreground [&_svg]:size-3 [&_i]:text-[0.7rem] [&_i]:leading-none"
const header = (title: string) => (
<div className={cn(headerBar, "justify-between")}>
<span className="text-xs font-semibold text-foreground">{title}</span>
<span className="flex items-center gap-0.5">
<span aria-hidden="true" className={btn}>
<Minus />
</span>
<span aria-hidden="true" className={btn}>
<Square />
</span>
<span aria-hidden="true" className={btn}>
<X />
</span>
</span>
</div>
)
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, height[size], className)}>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <CardPane header={header("Editor")} label="Panel bir" tone="bg-surface-2" />}
</div>
<div {...separatorProps} className={cn(dividerBase, focusRing, "bg-border hover:bg-primary/60")} />
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <CardPane header={header("Terminal")} label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* TabResizable: baslik cubugu tek aktif sekme gorunumunde. */
export function TabResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, separatorProps } = useResizable("horizontal")
const header = (title: string) => (
<div className={cn(headerBar, "gap-0 px-2")}>
<span className="-mb-px inline-flex h-full items-center rounded-t-md border-b-2 border-primary px-2 text-xs font-semibold text-foreground">
{title}
</span>
</div>
)
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, height[size], className)}>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <CardPane header={header("index.ts")} label="Panel bir" tone="bg-surface-2" />}
</div>
<div {...separatorProps} className={cn(dividerBase, focusRing, "bg-border hover:bg-primary/60")} />
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <CardPane header={header("styles.css")} label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Header
Each pane is a card with a plain title bar.
import { HeaderResizable } from "@/components/ui/resizable-panel"
<HeaderResizable />Titled
A title bar with a title and a muted subtitle line.
import { TitledResizable } from "@/components/ui/resizable-panel"
<TitledResizable />Badge
A title bar with a small badge on the right.
import { BadgeResizable } from "@/components/ui/resizable-panel"
<BadgeResizable />Toolbar
A title bar with a row of small icon buttons.
import { ToolbarResizable } from "@/components/ui/resizable-panel"
<ToolbarResizable />Tab
A title bar styled as a single active tab.
import { TabResizable } from "@/components/ui/resizable-panel"
<TabResizable />ai2 Panel resizables: 5 styled variations on the token system
The ai2 Panel resizables are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around draggable split panels whose panes are labeled cards with a header bar. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: there is no motion library here; only token color transitions on the divider and header controls. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, nothing changes; the divider only shifts color and dragging still resizes the panels.
What is in the ai2 Panel resizables?
5 exports in one file: Header, Titled, Badge, Toolbar and Tab. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: there is no motion library here; only token color transitions on the divider and header controls.
- Reduced-motion aware: Under prefers-reduced-motion, nothing changes; the divider only shifts color and dragging still resizes the panels.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Panel resizables sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.