Layout
Resizable.
Panels split by draggable handles, for editors and dashboards. Built on react-resizable-panels.
MonitorLeft
TerminalRight
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
function Pane({ label, hint }: { label: string; hint: string }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-4 text-center">
<span className="font-display text-sm font-medium">{label}</span>
<span className="font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">{hint}</span>
</div>
);
}
export default function ResizableDemo() {
return (
<ResizablePanelGroup orientation="horizontal" className="max-w-md rounded-lg border bg-card" style={{ height: 220 }}>
<ResizablePanel defaultSize="40%" minSize="20%">
<Pane label="Monitor" hint="Left" />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="60%" minSize="20%">
<Pane label="Terminal" hint="Right" />
</ResizablePanel>
</ResizablePanelGroup>
);
}Installation#
With the CLI
$npx lanterncn add resizableThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/resizable. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install react-resizable-panels lucide-reactCopy the source into your project
components/ui/resizable.tsx"use client"; import * as React from "react"; import { GripVerticalIcon } from "lucide-react"; import * as ResizablePrimitive from "react-resizable-panels"; import { cn } from "@/lib/utils"; function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) { return ( <ResizablePrimitive.Group data-slot="resizable-panel-group" className={cn("h-full w-full", className)} {...props} /> ); } function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />; } function ResizableHandle({ withHandle, className, ...props }: ResizablePrimitive.SeparatorProps & { withHandle?: boolean }) { return ( <ResizablePrimitive.Separator data-slot="resizable-handle" className={cn( // A 1px line with a wider invisible hit area. In a vertical group the separator reports aria-orientation="horizontal". "group/handle relative flex w-px items-center justify-center bg-border transition-colors outline-none", "after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2", "hover:bg-primary/70 focus-visible:bg-primary data-[separator=active]:bg-primary data-[separator=focus]:bg-primary data-[separator=hover]:bg-primary/70", "aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full", "aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-2 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2", "[&[aria-orientation=horizontal]>div]:rotate-90", className, )} {...props} > {withHandle && ( <div className="z-10 flex h-5 w-3 items-center justify-center rounded-sm border border-input bg-secondary text-muted-foreground transition-colors group-hover/handle:border-primary group-hover/handle:text-primary group-focus-visible/handle:border-primary group-focus-visible/handle:text-primary group-data-[separator=active]/handle:border-primary group-data-[separator=active]/handle:text-primary"> <GripVerticalIcon className="size-2.5" /> </div> )} </ResizablePrimitive.Separator> ); } export { ResizablePanelGroup, ResizablePanel, ResizableHandle };Update the import paths to match your project
The source imports
cnfrom@/lib/utils.
Usage#
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";<ResizablePanelGroup orientation="horizontal">
<ResizablePanel defaultSize="40%">Monitor</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="60%">Terminal</ResizablePanel>
</ResizablePanelGroup>This uses react-resizable-panels v4. The group takes orientation (not direction). Sizes given as numbers are pixels; use strings like "40%" for percentages.
Handles are keyboard accessible: focus one with Tab and use the arrow keys. Double-click a handle to reset its panel.
Examples#
Vertical
Guestbook
Entries
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
export default function ResizableVertical() {
return (
<ResizablePanelGroup orientation="vertical" className="max-w-md rounded-lg border bg-card" style={{ height: 260 }}>
<ResizablePanel defaultSize="35%" minSize="15%">
<div className="flex h-full items-center justify-center p-4">
<span className="font-display text-sm font-medium">Guestbook</span>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="65%" minSize="15%">
<div className="flex h-full items-center justify-center p-4">
<span className="font-display text-sm font-medium">Entries</span>
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}Nested editor
Files
- startup.lua
- hub.lua
- guestbook.lua
- mine.lua
hub.lua
1local modem = peripheral.find("modem")2local hub = require("hub")34while true do5 local id, msg = rednet.receive("lantern")6 hub.handle(id, msg)7end
Console
$ hub
Listening on lantern-main
import { FileIcon } from "lucide-react";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
const files = ["startup.lua", "hub.lua", "guestbook.lua", "mine.lua"];
const code = [
["local", " modem = peripheral.find(\"modem\")"],
["local", " hub = require(\"hub\")"],
["", ""],
["while", " true do"],
["", " local id, msg = rednet.receive(\"lantern\")"],
["", " hub.handle(id, msg)"],
["end", ""],
];
export default function ResizableEditor() {
return (
<ResizablePanelGroup
orientation="horizontal"
className="max-w-2xl rounded-lg border bg-card font-mono text-xs"
style={{ height: 340 }}
>
<ResizablePanel defaultSize="26%" minSize="18%" collapsible collapsedSize="0%">
<div className="flex h-full flex-col">
<div className="border-b px-3 py-2 text-[10px] font-semibold tracking-[0.2em] text-success uppercase">Files</div>
<ul className="grid gap-px p-1.5">
{files.map((f) => (
<li
key={f}
className={
f === "hub.lua"
? "flex items-center gap-2 truncate rounded-r-sm border-l border-primary bg-secondary px-2 py-1 text-foreground"
: "flex items-center gap-2 truncate border-l border-transparent px-2 py-1 text-muted-foreground"
}
>
<FileIcon className="size-3 shrink-0" />
<span className="truncate">{f}</span>
</li>
))}
</ul>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize="74%" minSize="40%">
<ResizablePanelGroup orientation="vertical">
<ResizablePanel defaultSize="68%" minSize="25%">
<div className="flex h-full flex-col">
<div className="border-b px-3 py-2 text-[10px] tracking-[0.2em] text-muted-foreground uppercase">hub.lua</div>
<pre className="flex-1 overflow-auto p-3 leading-relaxed">
{code.map(([kw, rest], i) => (
<div key={i}>
<span className="mr-4 inline-block w-4 text-right text-muted-foreground/60 select-none">{i + 1}</span>
<span className="text-primary">{kw}</span>
<span className="text-foreground">{rest}</span>
</div>
))}
</pre>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="32%" minSize="15%">
<div className="flex h-full flex-col bg-terminal">
<div className="border-b px-3 py-2 text-[10px] tracking-[0.2em] text-muted-foreground uppercase">Console</div>
<div className="flex-1 overflow-auto p-3 leading-relaxed text-terminal-foreground">
<div>
<span className="text-primary">$</span> hub
</div>
<div className="text-[#d9b774]">Listening on lantern-main</div>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
);
}