Ratio resizables
Five resizable splits that surface a live percentage while dragging: a pill on the handle, an inline vertical readout, a badge at the top, a floating tooltip and a fixed corner readout. The value comes from the handle position, rounded to a whole percent. 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-ratioDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/resizable-ratio.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Ratio resizable family: 5 self-contained split panels (NO panel library, NO
radix). The theme idea: while dragging, a live percentage label appears on or
beside the handle. The value comes from the pct the hook tracks; it is formatted
as Math.round(pct) + "%" (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. The motion is gated on useReducedMotion() (in reduced the label
appears without animation; the drag itself is always instant). */
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",
children,
}: {
label?: string
tone?: 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
)}
>
{children ?? <span className="text-muted-foreground">{label}</span>}
</div>
)
}
const shell =
"flex w-full select-none overflow-hidden rounded-xl border border-border"
const fmt = (pct: number) => `${Math.round(pct)}%`
/* PillResizable: tutamac ortasinda, suruklerken beliren primary yuvarlak yuzde etiketi. */
export function PillResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
const reduce = useReducedMotion()
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 flex h-full w-1 shrink-0 cursor-col-resize items-center justify-center transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-primary" : "bg-border hover:bg-primary/60"
)}
>
<AnimatePresence>
{dragging ? (
<motion.span
className="pointer-events-none z-10 rounded-full bg-primary px-2 py-0.5 text-[0.7rem] font-semibold tabular-nums text-primary-foreground shadow-sm"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{fmt(pct)}
</motion.span>
) : null}
</AnimatePresence>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* InlineResizable: a small percentage text embedded on the separator that appears
while dragging. */
export function InlineResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
const reduce = useReducedMotion()
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 flex h-full w-1.5 shrink-0 cursor-col-resize items-start justify-center bg-border transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1 after:content-['']",
dragging ? "bg-primary/60" : "hover:bg-primary/40"
)}
>
<AnimatePresence>
{dragging ? (
<motion.span
className="pointer-events-none z-10 mt-2 text-[0.65rem] font-semibold tabular-nums text-primary [writing-mode:vertical-rl]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.14 }}
>
{fmt(pct)}
</motion.span>
) : null}
</AnimatePresence>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* BadgeResizable: ayiricinin ust kenarinda, suruklerken beliren yuzde rozeti. */
export function BadgeResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
const reduce = useReducedMotion()
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 flex h-full w-px shrink-0 cursor-col-resize justify-center transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-primary" : "bg-border hover:bg-primary/60"
)}
>
<AnimatePresence>
{dragging ? (
<motion.span
className="pointer-events-none absolute top-1.5 z-10 rounded-md border border-border bg-background px-1.5 py-0.5 text-[0.65rem] font-semibold tabular-nums text-foreground shadow-sm"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
{fmt(pct)}
</motion.span>
) : null}
</AnimatePresence>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* TooltipResizable: tutamacin ustunde, ok isaretli baloncuk seklinde yuzde ipucu. */
export function TooltipResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
const reduce = useReducedMotion()
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 flex h-full w-1 shrink-0 cursor-col-resize items-center justify-center transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-primary" : "bg-border hover:bg-primary/60"
)}
>
<AnimatePresence>
{dragging ? (
<motion.span
className="pointer-events-none absolute top-2 z-10 flex flex-col items-center"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<span className="rounded-md bg-foreground px-2 py-0.5 text-[0.7rem] font-semibold tabular-nums text-background shadow-md">
{fmt(pct)}
</span>
<span className="-mt-px size-2 rotate-45 bg-foreground" />
</motion.span>
) : null}
</AnimatePresence>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* CornerResizable: yuzde, kabin sag ust kosesinde sabit bir okuyucu olarak gosterilir. */
export function CornerResizable({ className, size = "md", start, end }: ResizableProps) {
const { pct, containerRef, dragging, separatorProps } = useResizable("horizontal")
const reduce = useReducedMotion()
return (
<div data-slot="styled-resizable" ref={containerRef} className={cn(shell, "relative", 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-primary" : "bg-border hover:bg-primary/60"
)}
/>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
<AnimatePresence>
{dragging ? (
<motion.span
className="pointer-events-none absolute right-2 top-2 z-10 rounded-md border border-border bg-background/90 px-2 py-0.5 text-[0.7rem] font-semibold tabular-nums text-foreground shadow-sm supports-[backdrop-filter]:backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.14 }}
>
{fmt(pct)}
</motion.span>
) : null}
</AnimatePresence>
</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.
Pill
A rounded primary pill on the handle shows the live percentage.
import { PillResizable } from "@/components/ui/resizable-ratio"
<PillResizable />Inline
A small percentage reads vertically along the divider.
import { InlineResizable } from "@/components/ui/resizable-ratio"
<InlineResizable />Badge
A percentage badge drops in at the top of the divider.
import { BadgeResizable } from "@/components/ui/resizable-ratio"
<BadgeResizable />Tooltip
A tooltip bubble with an arrow floats above the handle.
import { TooltipResizable } from "@/components/ui/resizable-ratio"
<TooltipResizable />Corner
The percentage shows in the top-right corner of the frame.
import { CornerResizable } from "@/components/ui/resizable-ratio"
<CornerResizable />ai2 Ratio resizables: 5 styled variations on the token system
The ai2 Ratio resizables are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around draggable split panels that show a live rounded percentage while dragging. 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 fades and pops the percentage label in and out; 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 label appears and disappears without transform; dragging still resizes the panels.
What is in the ai2 Ratio resizables?
5 exports in one file: Pill, Inline, Badge, Tooltip and Corner. 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 fades and pops the percentage label in and out; the drag itself is instant.
- Reduced-motion aware: Under prefers-reduced-motion, the label appears and disappears without transform; 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 Ratio 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.