DeveloperPlaygroundLayout
The Developer Playground Layout template provides the shell behind in-browser coding environments (CodeSandbox, StackBlitz, CodePen): a file tab strip above an editor pane, a draggable and keyboard-resizable divider splitting it from a live preview pane, and an optional collapsible console along the bottom. The actual editor and preview content are supplied by the consumer as slots - this component only provides the surrounding shell.
- Preview
- Code
export default function App() {
return <h1>Hello, playground!</h1>;
}
Hello, playground!
> Compiled successfully.
> Listening on http://localhost:3000
import {
DeveloperPlaygroundLayout,
type PlaygroundFile,
} from '@ignix-ui/developer-playground-layout';
const files: PlaygroundFile[] = [{ name: 'App.tsx' }, { name: 'styles.css' }];
<DeveloperPlaygroundLayout
files={files}
activeFileName={activeFileName}
onActiveFileChange={setActiveFileName}
editor={<CodeEditor file={activeFileName} />}
preview={<PreviewFrame />}
consoleContent={logs.map((log, i) => <p key={i}>{log}</p>)}
onClearConsole={() => setLogs([])}
/>
Installation
- CLI
- Manual
ignix add component DeveloperPlaygroundLayout
cn is a small clsx + tailwind-merge helper (installed automatically by the CLI, at src/utils/cn.ts). Placing this file at src/templates/DeveloperPlaygroundLayout/index.tsx resolves the import below as-is; adjust the relative path if you place it elsewhere.
import * as React from "react";
import { ChevronDown, ChevronUp, Terminal, FileCode, Trash2 } from "lucide-react";
import { cn } from "../../utils/cn";
/* -------------------------------------------------------------------------- */
/* TYPES & INTERFACES */
/* -------------------------------------------------------------------------- */
/** A single file listed in the playground's tab strip. */
export interface PlaygroundFile {
name: string;
language?: string;
icon?: React.ReactNode;
}
export type PlaygroundOrientation = "horizontal" | "vertical";
/**
* Props for the {@link DeveloperPlaygroundLayout} template.
*
* @property header - Custom node rendered at the start of the top toolbar (e.g. a logo/title).
* @property actions - Optional node rendered at the end of the top toolbar (e.g. a "Run" button).
* @property files - Files listed in the tab strip above the editor. Omit to hide the tab strip
* entirely (e.g. for a single-file playground).
* @property activeFileName - Controlled active file name. Provide alongside `onActiveFileChange`
* to drive the active tab externally; omit to let the layout manage its own state.
* @property onActiveFileChange - Called whenever the active file changes, whether controlled or
* not.
* @property editor - Editor content for the currently active file. This component only provides
* the shell (tabs, pane, split) - swapping the actual editor content per file is the consumer's
* responsibility (typically driven by `activeFileName`).
* @property preview - Live preview content, rendered in the other pane of the split. Omit to hide
* the divider and preview pane entirely, letting the editor fill the container.
* @property consoleContent - Content for the collapsible console panel at the bottom. Omit to
* hide the console entirely.
* @property onClearConsole - Called when the console's clear button is clicked. The layout does
* not own the console's contents (`consoleContent` is an opaque node), so clearing is delegated
* entirely to the consumer via this callback. The clear button only renders when both `consoleContent`
* and this callback are provided.
* @property showConsole - Controlled console-expanded state. Provide alongside
* `onShowConsoleChange` to drive it externally; omit to let the layout manage its own state.
* @property defaultShowConsole - Initial console-expanded state when uncontrolled. Default `true`.
* @property onShowConsoleChange - Called whenever the console's expanded state changes, whether
* controlled or not.
* @property orientation - Split direction between the editor and preview panes. Default
* `"horizontal"` (side by side).
* @property splitPercentage - Controlled editor-pane size, as a percentage of the split axis.
* Provide alongside `onSplitChange` to drive it externally; omit to let the layout manage its
* own state.
* @property defaultSplitPercentage - Initial editor-pane percentage when uncontrolled. Default
* `50`.
* @property onSplitChange - Called whenever the split changes, whether controlled or not.
* @property minSplitPercentage - Minimum editor-pane percentage. Default `20`.
* @property maxSplitPercentage - Maximum editor-pane percentage. Default `80`.
* @property className - Class name for the root container. The root fills its parent's height
* (`h-full`), not the viewport - give it a sized ancestor, or pass a height utility here (e.g.
* `h-screen`) to make it fill the viewport directly.
*/
export interface DeveloperPlaygroundLayoutProps {
header?: React.ReactNode;
actions?: React.ReactNode;
files?: PlaygroundFile[];
activeFileName?: string;
onActiveFileChange?: (name: string) => void;
editor: React.ReactNode;
preview?: React.ReactNode;
consoleContent?: React.ReactNode;
onClearConsole?: () => void;
showConsole?: boolean;
defaultShowConsole?: boolean;
onShowConsoleChange?: (show: boolean) => void;
orientation?: PlaygroundOrientation;
splitPercentage?: number;
defaultSplitPercentage?: number;
onSplitChange?: (percentage: number) => void;
minSplitPercentage?: number;
maxSplitPercentage?: number;
className?: string;
}
const DEFAULT_SPLIT = 50;
const SPLIT_KEYBOARD_STEP = 5;
/** Restricts `value` to the inclusive `[min, max]` range. */
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/* -------------------------------------------------------------------------- */
/* MAIN COMPONENT */
/* -------------------------------------------------------------------------- */
/**
* DeveloperPlaygroundLayout is a code-playground shell - the shape behind in-browser coding
* environments (CodeSandbox, StackBlitz, CodePen). A file tab strip sits above an editor pane, a
* draggable/keyboard-resizable divider splits it from a live preview pane, and an optional
* collapsible console sits along the bottom.
*/
export const DeveloperPlaygroundLayout: React.FC<DeveloperPlaygroundLayoutProps> = ({
header,
actions,
files,
activeFileName,
onActiveFileChange,
editor,
preview,
consoleContent,
onClearConsole,
showConsole,
defaultShowConsole = true,
onShowConsoleChange,
orientation = "horizontal",
splitPercentage,
defaultSplitPercentage = DEFAULT_SPLIT,
onSplitChange,
minSplitPercentage = 20,
maxSplitPercentage = 80,
className,
}) => {
const isActiveFileControlled = activeFileName !== undefined;
const [internalActiveFile, setInternalActiveFile] = React.useState<string | undefined>(files?.[0]?.name);
const currentActiveFile = isActiveFileControlled ? activeFileName : internalActiveFile;
const selectFile = React.useCallback(
(name: string) => {
if (!isActiveFileControlled) setInternalActiveFile(name);
onActiveFileChange?.(name);
},
[isActiveFileControlled, onActiveFileChange]
);
// `files` can change after mount (populated asynchronously, or the active file removed) -
// the useState initializer above only runs once, so re-sync when uncontrolled and the
// current selection no longer points at a file that exists.
React.useEffect(() => {
if (isActiveFileControlled) return;
const stillExists = files?.some((file) => file.name === internalActiveFile) ?? false;
if (!stillExists) {
const fallback = files?.[0]?.name;
setInternalActiveFile(fallback);
if (fallback !== undefined) onActiveFileChange?.(fallback);
}
}, [files, isActiveFileControlled, internalActiveFile, onActiveFileChange]);
const isConsoleControlled = showConsole !== undefined;
const [internalShowConsole, setInternalShowConsole] = React.useState(defaultShowConsole);
const currentShowConsole = isConsoleControlled ? showConsole : internalShowConsole;
const toggleConsole = React.useCallback(() => {
const next = !currentShowConsole;
if (!isConsoleControlled) setInternalShowConsole(next);
onShowConsoleChange?.(next);
}, [currentShowConsole, isConsoleControlled, onShowConsoleChange]);
// Guards against an inverted range (max < min would freeze the split at a fixed value), the
// same defensive pattern used for zoom bounds elsewhere in this template family.
const safeMin = Number.isFinite(minSplitPercentage) ? minSplitPercentage : 20;
const safeMax = Number.isFinite(maxSplitPercentage) ? Math.max(maxSplitPercentage, safeMin) : Math.max(safeMin, 80);
const isSplitControlled = splitPercentage !== undefined;
const [internalSplit, setInternalSplit] = React.useState(clamp(defaultSplitPercentage, safeMin, safeMax));
const rawSplit = isSplitControlled ? splitPercentage : internalSplit;
const currentSplit = Number.isFinite(rawSplit) ? clamp(rawSplit, safeMin, safeMax) : clamp(DEFAULT_SPLIT, safeMin, safeMax);
const updateSplit = React.useCallback(
(next: number) => {
const clamped = clamp(next, safeMin, safeMax);
if (!isSplitControlled) setInternalSplit(clamped);
onSplitChange?.(clamped);
},
[isSplitControlled, safeMin, safeMax, onSplitChange]
);
const splitContainerRef = React.useRef<HTMLDivElement>(null);
const dragPointerIdRef = React.useRef<number | null>(null);
const handleDividerPointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
dragPointerIdRef.current = event.pointerId;
};
const handleDividerPointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
if (dragPointerIdRef.current !== event.pointerId) return;
const container = splitContainerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const percentage =
orientation === "horizontal"
? ((event.clientX - rect.left) / rect.width) * 100
: ((event.clientY - rect.top) / rect.height) * 100;
updateSplit(percentage);
};
const handleDividerPointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
if (dragPointerIdRef.current === event.pointerId) {
dragPointerIdRef.current = null;
}
};
const handleDividerKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
const decreaseKey = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
const increaseKey = orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
switch (event.key) {
case decreaseKey:
updateSplit(currentSplit - SPLIT_KEYBOARD_STEP);
event.preventDefault();
break;
case increaseKey:
updateSplit(currentSplit + SPLIT_KEYBOARD_STEP);
event.preventDefault();
break;
case "Home":
updateSplit(safeMin);
event.preventDefault();
break;
case "End":
updateSplit(safeMax);
event.preventDefault();
break;
default:
break;
}
};
return (
<div className={cn("flex h-full min-h-0 w-full flex-col bg-[var(--background)] text-[var(--foreground)]", className)}>
{(header || actions) && (
<header
className="z-10 flex h-14 w-full shrink-0 items-center gap-3 border-b border-[var(--border)] bg-[var(--background)] px-4"
role="banner"
>
{header && <div className="flex shrink-0 items-center">{header}</div>}
{actions && <div className="ml-auto flex shrink-0 items-center gap-2">{actions}</div>}
</header>
)}
<div
ref={splitContainerRef}
className={cn("flex flex-1 overflow-hidden", orientation === "vertical" ? "flex-col" : "flex-col md:flex-row")}
>
<div
className={cn("flex min-h-0 min-w-0 flex-col overflow-hidden", preview == null && "flex-1")}
style={preview != null ? { flexBasis: `${currentSplit}%` } : undefined}
>
{files && files.length > 0 && (
<div role="tablist" aria-label="Files" className="flex shrink-0 overflow-x-auto border-b border-[var(--border)]">
{files.map((file) => {
const isActive = file.name === currentActiveFile;
return (
<button
key={file.name}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => selectFile(file.name)}
className={cn(
"flex shrink-0 items-center gap-1.5 border-r border-[var(--border)] px-3 py-2 text-sm",
isActive
? "bg-[var(--background)] text-[var(--foreground)]"
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
)}
>
{file.icon ?? <FileCode className="h-3.5 w-3.5" aria-hidden="true" />}
{file.name}
</button>
);
})}
</div>
)}
<div className="min-h-0 flex-1 overflow-auto">{editor}</div>
</div>
{preview != null && (
<>
<div
role="separator"
aria-orientation={orientation === "horizontal" ? "vertical" : "horizontal"}
aria-label="Resize editor and preview panes"
aria-valuenow={Math.round(currentSplit)}
aria-valuemin={Math.round(safeMin)}
aria-valuemax={Math.round(safeMax)}
tabIndex={0}
className={cn(
"shrink-0 touch-none bg-[var(--border)] transition-colors hover:bg-[var(--primary)] focus-visible:outline-none focus-visible:bg-[var(--primary)]",
orientation === "horizontal" ? "hidden w-1 cursor-col-resize md:block" : "h-1 cursor-row-resize"
)}
onPointerDown={handleDividerPointerDown}
onPointerMove={handleDividerPointerMove}
onPointerUp={handleDividerPointerUp}
onPointerCancel={handleDividerPointerUp}
onKeyDown={handleDividerKeyDown}
/>
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{preview}</div>
</>
)}
</div>
{consoleContent != null && (
<div className="shrink-0 border-t border-[var(--border)] bg-[var(--background)]">
<div className="flex items-center justify-between px-3 py-1.5">
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--muted-foreground)]">
<Terminal className="h-3.5 w-3.5" aria-hidden="true" />
Console
</div>
<div className="flex items-center gap-0.5">
{onClearConsole && (
<button
type="button"
aria-label="Clear console"
onClick={onClearConsole}
className="rounded p-1 text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
<button
type="button"
aria-label={currentShowConsole ? "Collapse console" : "Expand console"}
aria-expanded={currentShowConsole}
onClick={toggleConsole}
className="rounded p-1 text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]"
>
{currentShowConsole ? (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
)}
</button>
</div>
</div>
{currentShowConsole && <div className="max-h-40 overflow-auto border-t border-[var(--border)]">{consoleContent}</div>}
</div>
)}
</div>
);
};
DeveloperPlaygroundLayout.displayName = "DeveloperPlaygroundLayout";
export default DeveloperPlaygroundLayout;
Usage
import { DeveloperPlaygroundLayout, type PlaygroundFile } from '@ignix-ui/developer-playground-layout';
Basic Usage
function App() {
return (
<DeveloperPlaygroundLayout
editor={<CodeEditor />}
preview={<PreviewFrame />}
/>
);
}
With File Tabs
import { useState } from 'react';
const files: PlaygroundFile[] = [{ name: 'App.tsx' }, { name: 'styles.css' }];
function App() {
const [activeFileName, setActiveFileName] = useState(files[0].name);
return (
<DeveloperPlaygroundLayout
files={files}
activeFileName={activeFileName}
onActiveFileChange={setActiveFileName}
editor={<CodeEditor file={activeFileName} />}
preview={<PreviewFrame />}
/>
);
}
With a Console
import { useState } from 'react';
function App() {
const [logs, setLogs] = useState(['Compiled successfully.']);
return (
<DeveloperPlaygroundLayout
editor={<CodeEditor />}
preview={<PreviewFrame />}
consoleContent={logs.map((log, i) => <p key={i}>{log}</p>)}
onClearConsole={() => setLogs([])}
/>
);
}
Vertical Split
<DeveloperPlaygroundLayout
orientation="vertical"
editor={<CodeEditor />}
preview={<PreviewFrame />}
/>
Controlled Split
import { useState } from 'react';
function App() {
const [splitPercentage, setSplitPercentage] = useState(50);
return (
<DeveloperPlaygroundLayout
splitPercentage={splitPercentage}
onSplitChange={setSplitPercentage}
editor={<CodeEditor />}
preview={<PreviewFrame />}
/>
);
}
Interaction Reference
| Input | Action |
|---|---|
| Drag the divider | Resize the editor and preview panes |
| Arrow keys (divider focused) | Nudge the split by 5% |
| Home / End (divider focused) | Snap the split to minSplitPercentage / maxSplitPercentage |
| Click a file tab | Switch the active file |
| Console collapse/expand button | Toggle the console panel |
| Clear console button | Calls onClearConsole (shown only when both consoleContent and onClearConsole are provided) |
Props
DeveloperPlaygroundLayout
| Prop | Type | Default | Description |
|---|---|---|---|
header | React.ReactNode | undefined | Custom node rendered at the start of the top toolbar (e.g. a logo/title) |
actions | React.ReactNode | undefined | Optional node rendered at the end of the top toolbar (e.g. a "Run" button) |
files | PlaygroundFile[] | undefined | Files listed in the tab strip; omit to hide the tab strip entirely |
activeFileName | string | undefined | Controlled active file name; provide alongside onActiveFileChange to drive it externally |
onActiveFileChange | (name: string) => void | undefined | Called whenever the active file changes, whether controlled or not |
editor | React.ReactNode | — | Editor content for the currently active file |
preview | React.ReactNode | undefined | Live preview content, rendered in the other pane of the split. Omit to hide the divider and preview pane, letting the editor fill the container |
consoleContent | React.ReactNode | undefined | Content for the collapsible console panel; omit to hide the console entirely |
onClearConsole | () => void | undefined | Called when the clear-console button is clicked. The button only renders when both consoleContent and this callback are provided; clearing the actual content is the consumer's responsibility |
showConsole | boolean | undefined | Controlled console-expanded state; provide alongside onShowConsoleChange to drive it externally |
defaultShowConsole | boolean | true | Initial console-expanded state when uncontrolled |
onShowConsoleChange | (show: boolean) => void | undefined | Called whenever the console's expanded state changes, whether controlled or not |
orientation | "horizontal" | "vertical" | "horizontal" | Split direction between the editor and preview panes |
splitPercentage | number | undefined | Controlled editor-pane size as a percentage; provide alongside onSplitChange to drive it externally |
defaultSplitPercentage | number | 50 | Initial editor-pane percentage when uncontrolled |
onSplitChange | (percentage: number) => void | undefined | Called whenever the split changes, whether controlled or not |
minSplitPercentage | number | 20 | Minimum editor-pane percentage |
maxSplitPercentage | number | 80 | Maximum editor-pane percentage |
className | string | undefined | Additional CSS classes applied to the root container. The root fills its parent's height (h-full), not the viewport |
PlaygroundFile
| Prop | Type | Default | Description |
|---|---|---|---|
name | string | — | File name, shown in its tab |
language | string | undefined | Optional language identifier (for consumer-side syntax highlighting) |
icon | React.ReactNode | undefined | Optional icon shown in the tab; defaults to a generic file icon |