Accent resizables
Five resizable splits with a primary-colored handle that highlights while dragging: a stretching bar, a glowing bar, a filling ring knob, a solid-fill track and lit panel edges. 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-accentDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/resizable-accent.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Accent resizable family: 5 self-contained split panels (NO panel library, NO
radix). The theme idea: a primary-colored handle that glows / stands out while
dragging (the dragging state). 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. Where
there is motion it is gated on useReducedMotion() (in reduced the handle glow
falls back to an instant color transition). */
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"
const spring = { type: "spring" as const, stiffness: 500, damping: 30 }
/* BarResizable: primary dikey cubuk; suruklerken opaklik/olcek artar. */
export function BarResizable({ 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-center justify-center bg-border transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1 after:content-['']",
dragging ? "bg-primary/20" : "hover:bg-primary/15"
)}
>
<motion.span
aria-hidden="true"
className={cn("h-10 w-1 rounded-full", dragging ? "bg-primary" : "bg-primary/60")}
animate={reduce ? undefined : { scaleY: dragging ? 1.15 : 1 }}
transition={reduce ? { duration: 0 } : spring}
/>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* GlowResizable: primary cubuk, suruklerken cevresine primary isik halesi (box-shadow) yayar. */
export function GlowResizable({ 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-center justify-center",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1 after:content-['']"
)}
>
<motion.span
aria-hidden="true"
className={cn(
"h-full w-1 rounded-full transition-shadow",
dragging
? "bg-primary shadow-[0_0_14px_2px_color-mix(in_oklab,var(--color-primary)_65%,transparent)]"
: "bg-primary/70 shadow-[0_0_0_0_color-mix(in_oklab,var(--color-primary)_0%,transparent)]"
)}
animate={reduce ? undefined : { scale: dragging ? 1.05 : 1 }}
transition={reduce ? { duration: 0 } : spring}
/>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* RingResizable: ortada primary halkali yuvarlak tutamac; suruklerken ici primary ile dolar. */
export function RingResizable({ 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 bg-border transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']",
dragging ? "bg-primary/40" : "hover:bg-primary/20"
)}
>
<motion.span
aria-hidden="true"
className={cn(
"z-10 size-4 rounded-full border-2 border-primary transition-colors",
dragging ? "bg-primary" : "bg-background"
)}
animate={reduce ? undefined : { scale: dragging ? 1.2 : 1 }}
transition={reduce ? { duration: 0 } : spring}
/>
</div>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* FillResizable: a thin line; while dragging the whole rail fills with a primary tint. */
export function FillResizable({ 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-2 shrink-0 cursor-col-resize transition-colors",
focusRing,
"after:absolute after:inset-y-0 after:-inset-x-1 after:content-['']",
dragging ? "bg-primary" : "bg-primary/25 hover:bg-primary/40"
)}
/>
<div style={{ width: `${100 - pct}%` }} className="h-full">
{end ?? <Pane label="Panel iki" tone="bg-surface-3" />}
</div>
</div>
)
}
/* EdgeResizable: a thin separator; while dragging the neighbouring panel edges light up with a primary line. */
export function EdgeResizable({ 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={cn(
"h-full border-r-2 transition-colors",
dragging ? "border-primary" : "border-transparent"
)}
>
{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/50"
)}
/>
<div
style={{ width: `${100 - pct}%` }}
className={cn(
"h-full border-l-2 transition-colors",
dragging ? "border-primary" : "border-transparent"
)}
>
{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.
Bar
A primary bar that brightens and stretches while dragging.
import { BarResizable } from "@/components/ui/resizable-accent"
<BarResizable />Glow
A primary bar that casts a primary glow while dragging.
import { GlowResizable } from "@/components/ui/resizable-accent"
<GlowResizable />Ring
A ringed knob at the center that fills with primary while dragging.
import { RingResizable } from "@/components/ui/resizable-accent"
<RingResizable />Fill
A thin track that fills solid primary while dragging.
import { FillResizable } from "@/components/ui/resizable-accent"
<FillResizable />Edge
The adjacent panel edges light up primary while dragging.
import { EdgeResizable } from "@/components/ui/resizable-accent"
<EdgeResizable />ai2 Accent resizables: 5 styled variations on the token system
The ai2 Accent 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 primary handle that highlights on drag. 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 handle scale 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 handle stays static and only shifts color; dragging still resizes the panels.
What is in the ai2 Accent resizables?
5 exports in one file: Bar, Glow, Ring, Fill and Edge. 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 handle scale on drag; the drag itself is instant.
- Reduced-motion aware: Under prefers-reduced-motion, the handle stays static and only shifts color; 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 Accent 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.