Minimal resizables
Five minimal resizable splits that keep the handle almost invisible: a line, a fainter hair, a single dot, a hover-only ghost and a bare cursor-driven divider. 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-minimalDependencies, the @ai2/tokens theme and the component file are installed together.
Copy the source
components/ui/resizable-minimal.tsx"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
/* Minimal resizable family: 5 self-contained split panels (NO panel library, NO
radix). The theme idea: the handle is almost invisible - a hairline, no grip
dots, very quiet. 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 (only color transitions), so there is no motion import either. */
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
}
/* 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>
)
}
const shell =
"flex w-full select-none overflow-hidden rounded-xl border border-border"
/* LineResizable: a hairline single-pixel line; it darkens slightly on hover. */
export function LineResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, 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 h-full w-px shrink-0 cursor-col-resize transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-foreground/60" : "bg-border hover:bg-foreground/40"
)}
/>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* HairResizable: even fainter than LineResizable; at rest it almost disappears. */
export function HairResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, 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 h-full w-px shrink-0 cursor-col-resize transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-foreground/40" : "bg-border/60 hover:bg-border"
)}
/>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* DotResizable: a single small dot sitting on an invisible rail (not a cluster of grip dots). */
export function DotResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, 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-center justify-center bg-border",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']"
)}
>
<span
aria-hidden="true"
className={cn(
"size-1 rounded-full transition-colors",
dragging ? "bg-foreground" : "bg-muted-foreground/50 group-hover:bg-foreground/70"
)}
/>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* GhostResizable: no line at rest; it appears only on hover or while dragging. */
export function GhostResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, 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 h-full w-px shrink-0 cursor-col-resize transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-foreground/40" : "bg-transparent hover:bg-foreground/20"
)}
/>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* BareResizable: the barest form; no line at all, just the cursor and a very faint trace while dragging. The sense of separation comes entirely from the cursor. */
export function BareResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, 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 h-full w-px shrink-0 cursor-col-resize transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-2 after:content-['']",
dragging ? "bg-foreground/20" : "bg-transparent"
)}
/>
<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.
Line
A single hairline divider that darkens slightly on hover.
import { LineResizable } from "@/components/ui/resizable-minimal"
<LineResizable />Hair
An even fainter hairline that all but vanishes at rest.
import { HairResizable } from "@/components/ui/resizable-minimal"
<HairResizable />Dot
One small centered dot on an otherwise invisible track.
import { DotResizable } from "@/components/ui/resizable-minimal"
<DotResizable />Ghost
No line at rest; the divider appears only on hover or drag.
import { GhostResizable } from "@/components/ui/resizable-minimal"
<GhostResizable />Bare
The barest handle: no line, just the cursor and a faint drag trace.
import { BareResizable } from "@/components/ui/resizable-minimal"
<BareResizable />ai2 Minimal resizables: 5 styled variations on the token system
The ai2 Minimal resizables are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around draggable split panels with a near-invisible hairline handle. 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. 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 dividers only shift color and dragging still resizes the panels.
What is in the ai2 Minimal resizables?
5 exports in one file: Line, Hair, Dot, Ghost and Bare. 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.
- Reduced-motion aware: Under prefers-reduced-motion, nothing changes; the dividers only shift 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 Minimal 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.