Styled resizable
Five resizable splits: horizontal, vertical, handle, nested and glass. 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-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/resizable-styled.tsx"use client"
import * as React from "react"
import { GripHorizontal, GripVertical } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Resizable family: 5 self-contained split-panel components (NO
react-resizable-panels, NO radix). Each holds an internal percentage (pct) state;
dragging the handle with the pointer updates pct, and it can also be 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 (never var(--x)/0.4).
Gated on framer's useReducedMotion (under reduced motion the handle spring is
disabled; the drag itself is always instant). size -> container height. */
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 }
}
/* Varsayilan panel: token yuzeyli, ortalanmis etiket. */
function Pane({
label,
tone = "bg-surface-2",
className,
children,
}: {
label?: string
tone?: string
className?: string
children?: React.ReactNode
}) {
return (
<div
className={cn(
"flex h-full w-full items-center justify-center overflow-hidden p-4 text-sm font-medium text-foreground",
tone,
className
)}
>
{children ?? <span className="text-muted-foreground">{label}</span>}
</div>
)
}
interface ResizableProps {
className?: string
size?: StyledSize
start?: React.ReactNode
end?: React.ReactNode
}
/* The grip dots inside the handle: they grow slightly on hover and while active (fixed under reduced). */
function Grip({
orientation,
active,
}: {
orientation: Orientation
active: boolean
}) {
const reduce = useReducedMotion()
const Icon = orientation === "horizontal" ? GripVertical : GripHorizontal
return (
<motion.span
aria-hidden="true"
className="pointer-events-none flex items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"
animate={reduce ? undefined : { scale: active ? 1.15 : 1 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 30 }}
>
<Icon />
</motion.span>
)
}
/* HorizontalResizable: sol/sag panel, ince dikey ayirici cizgisi (grip yok). */
export function HorizontalResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div
data-slot="styled-resizable"
ref={containerRef}
className={cn(
"flex w-full select-none overflow-hidden rounded-xl border border-border",
height[size],
className
)}
>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <Pane label="Panel bir" tone="bg-surface-2" />}
</div>
<div
{...separatorProps}
className={cn(
"group relative flex h-full w-px shrink-0 cursor-col-resize items-stretch bg-border transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-primary" : "hover:bg-primary/70"
)}
/>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* VerticalResizable: ust/alt panel, ince yatay ayirici cizgisi. */
export function VerticalResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("vertical")
return (
<div
data-slot="styled-resizable"
ref={containerRef}
className={cn(
"flex w-full select-none flex-col overflow-hidden rounded-xl border border-border",
height[size],
className
)}
>
<div style={{ height: `${pct}%` }} className="w-full">
{start ?? <Pane label="Ust panel" tone="bg-surface-2" />}
</div>
<div
{...separatorProps}
className={cn(
"relative flex w-full h-px shrink-0 cursor-row-resize bg-border transition-colors",
focusRing,
"after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']",
dragging ? "bg-primary" : "hover:bg-primary/70"
)}
/>
<div style={{ height: `${100 - pct}%` }} className="w-full">
{end ?? <Pane label="Alt panel" tone="bg-surface-3" />}
</div>
</div>
)
}
/* HandleResizable: sol/sag panel, ortada gorunur kavrama tutamacli genis ayirici. */
export function HandleResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div
data-slot="styled-resizable"
ref={containerRef}
className={cn(
"flex w-full select-none overflow-hidden rounded-xl border border-border",
height[size],
className
)}
>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <Pane label="Panel bir" tone="bg-surface-2" />}
</div>
<div
{...separatorProps}
className={cn(
"relative flex h-full w-1.5 shrink-0 cursor-col-resize items-center justify-center bg-border transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1 after:content-['']",
dragging ? "bg-primary/40" : "hover:bg-primary/25"
)}
>
<span
className={cn(
"z-10 flex h-8 items-center justify-center rounded-full border border-border bg-background shadow-sm transition-colors",
dragging && "border-primary"
)}
>
<Grip orientation="horizontal" active={dragging} />
</span>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* NestedResizable: a left/right split; the right side splits again into top/bottom
within itself. */
export function NestedResizable({ className, size = "md", start, end }: ResizableProps) {
const outer = useResizable("horizontal", 42)
const inner = useResizable("vertical", 55)
return (
<div
data-slot="styled-resizable"
ref={outer.containerRef}
className={cn(
"flex w-full select-none overflow-hidden rounded-xl border border-border",
height[size],
className
)}
>
<div style={{ width: `${outer.pct}%` }} className="h-full">
{start ?? <Pane label="Kenar cubugu" tone="bg-surface-3" />}
</div>
<div
{...outer.separatorProps}
className={cn(
"relative flex h-full w-px shrink-0 cursor-col-resize bg-border transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
outer.dragging ? "bg-primary" : "hover:bg-primary/70"
)}
/>
<div style={{ width: `${100 - outer.pct}%` }} className="h-full">
<div ref={inner.containerRef} className="flex h-full w-full flex-col">
<div style={{ height: `${inner.pct}%` }} className="w-full">
{end ?? <Pane label="Icerik" tone="bg-surface-2" />}
</div>
<div
{...inner.separatorProps}
className={cn(
"relative flex h-px w-full shrink-0 cursor-row-resize bg-border transition-colors",
focusRing,
"after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']",
inner.dragging ? "bg-primary" : "hover:bg-primary/70"
)}
/>
<div style={{ height: `${100 - inner.pct}%` }} className="w-full">
<Pane label="Konsol" tone="bg-muted" />
</div>
</div>
</div>
</div>
)
}
/* GlassResizable: sol/sag panel, frosted cam ayirici (backdrop-blur + color-mix yuzey). */
export function GlassResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div
data-slot="styled-resizable"
ref={containerRef}
className={cn(
"flex w-full select-none overflow-hidden rounded-xl border border-border",
height[size],
className
)}
>
<div style={{ width: `${pct}%` }} className="h-full">
{start ?? <Pane label="Panel bir" tone="bg-surface-2" />}
</div>
<div
{...separatorProps}
className={cn(
"relative flex h-full w-2 shrink-0 cursor-col-resize items-center justify-center",
"border-x border-[color-mix(in_oklab,var(--color-foreground)_12%,transparent)]",
"bg-[color-mix(in_oklab,var(--color-background)_55%,transparent)] transition-colors supports-[backdrop-filter]:backdrop-blur-md",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1 after:content-['']",
dragging && "bg-[color-mix(in_oklab,var(--color-primary)_30%,transparent)]"
)}
>
<span
aria-hidden="true"
className={cn(
"z-10 h-8 w-1 rounded-full transition-colors",
dragging
? "bg-primary"
: "bg-[color-mix(in_oklab,var(--color-foreground)_35%,transparent)]"
)}
/>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane 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.
Horizontal
Two side-by-side panels with a draggable vertical divider.
import { HorizontalResizable } from "@/components/ui/resizable-styled"
<HorizontalResizable />Vertical
Stacked top and bottom panels with a draggable horizontal divider.
import { VerticalResizable } from "@/components/ui/resizable-styled"
<VerticalResizable />Handle
A horizontal split with a visible grip handle on the divider.
import { HandleResizable } from "@/components/ui/resizable-styled"
<HandleResizable />Nested
A sidebar split whose right side splits again into content and console.
import { NestedResizable } from "@/components/ui/resizable-styled"
<NestedResizable />Glass
A horizontal split with a frosted glass divider.
import { GlassResizable } from "@/components/ui/resizable-styled"
<GlassResizable />ai2 Styled resizable: 5 styled variations on the token system
The ai2 Styled resizable are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around draggable split panels. 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: framer-motion springs the grip handle on drag; the drag itself is instant. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the grip handle stays static; dragging still resizes the panels.
What is in the ai2 Styled resizable?
5 exports in one file: Horizontal, Vertical, Handle, Nested and Glass. 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: framer-motion springs the grip handle on drag; the drag itself is instant.
- Reduced-motion aware: Under prefers-reduced-motion, the grip handle stays static; 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 Styled resizable 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.