Canvas Shell
Layout shell for canvas workspaces with top bar, left rail, right dock, and bottom slot regions. Install Canvas Shell from the VLLNT UI registry with the shadcn CLI.
Layout shell for canvas workspaces with top bar, left rail, right dock, and bottom slot regions. Part of the utility family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Canvas Shell to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/canvas-shell.jsonSource
import type { CSSProperties, ReactNode } from "react";
import { cn } from "../../lib/utils";
import type { CanvasShellInsets } from "./canvas-shell-route-config";
export type CanvasShellProps = React.ComponentPropsWithoutRef<"section"> & {
bottomBar?: ReactNode;
bottomSlot?: ReactNode;
children?: ReactNode;
chromeInset?: number | string;
contentPadding?: CanvasShellInsets;
leftBar?: ReactNode;
leftRail?: ReactNode;
rightBar?: ReactNode;
rightDock?: ReactNode;
topBar?: ReactNode;
};
type CanvasShellChromeProps = {
bottomBar?: ReactNode;
inset: string;
leftBar?: ReactNode;
rightBar?: ReactNode;
topBar?: ReactNode;
};
type CanvasShellSafeAreaStyle = CSSProperties & {
"--canvas-shell-safe-bottom": string;
"--canvas-shell-safe-left": string;
"--canvas-shell-safe-right": string;
"--canvas-shell-safe-top": string;
};
function toInsetValue(value: number | string | undefined) {
if (typeof value === "number") {
return `${value}px`;
}
return value;
}
const FLOATING_BOTTOM_BAR_FOOTPRINT = "3.5rem";
const FLOATING_LEFT_BAR_FOOTPRINT = "4.5rem";
const FLOATING_RIGHT_BAR_FOOTPRINT = "18rem";
const FLOATING_TOP_BAR_FOOTPRINT = "3.5rem";
function getReservedInset(
inset: string,
footprint: string,
override: number | string | undefined,
) {
return toInsetValue(override) ?? `calc(${inset} + ${footprint})`;
}
function getSafeAreaInsets({
chromeInset,
contentPadding,
hasBottomBar,
hasLeftBar,
hasRightBar,
hasTopBar,
}: {
chromeInset: number | string;
contentPadding?: CanvasShellInsets;
hasBottomBar: boolean;
hasLeftBar: boolean;
hasRightBar: boolean;
hasTopBar: boolean;
}) {
const inset = toInsetValue(chromeInset) ?? "16px";
return {
bottom: hasBottomBar
? getReservedInset(
inset,
FLOATING_BOTTOM_BAR_FOOTPRINT,
contentPadding?.bottom,
)
: (toInsetValue(contentPadding?.bottom) ?? inset),
left: hasLeftBar
? getReservedInset(
inset,
FLOATING_LEFT_BAR_FOOTPRINT,
contentPadding?.left,
)
: (toInsetValue(contentPadding?.left) ?? inset),
right: hasRightBar
? getReservedInset(
inset,
FLOATING_RIGHT_BAR_FOOTPRINT,
contentPadding?.right,
)
: (toInsetValue(contentPadding?.right) ?? inset),
top: hasTopBar
? getReservedInset(inset, FLOATING_TOP_BAR_FOOTPRINT, contentPadding?.top)
: (toInsetValue(contentPadding?.top) ?? inset),
};
}
function getSafeAreaStyle(
insets: ReturnType<typeof getSafeAreaInsets>,
): CanvasShellSafeAreaStyle {
return {
"--canvas-shell-safe-bottom": insets.bottom,
"--canvas-shell-safe-left": insets.left,
"--canvas-shell-safe-right": insets.right,
"--canvas-shell-safe-top": insets.top,
} satisfies CanvasShellSafeAreaStyle;
}
const hasChromeContent = Boolean;
type CanvasShellChromeAfterProps = Pick<
CanvasShellChromeProps,
"bottomBar" | "inset" | "rightBar"
>;
function CanvasShellChromeBefore({
inset,
leftBar,
topBar,
}: Pick<CanvasShellChromeProps, "inset" | "leftBar" | "topBar">) {
return (
<>
{hasChromeContent(topBar) ? (
<div
className="pointer-events-none absolute inset-x-0 z-30"
style={{ top: inset }}
>
<div
className="pointer-events-auto mx-auto w-full max-w-[960px]"
style={{ paddingLeft: inset, paddingRight: inset }}
>
{topBar}
</div>
</div>
) : null}
{hasChromeContent(leftBar) ? (
<div
className="pointer-events-none absolute left-0 z-30 flex"
style={{
bottom: "var(--canvas-shell-safe-bottom)",
left: inset,
top: "var(--canvas-shell-safe-top)",
}}
>
<div className="pointer-events-auto flex">{leftBar}</div>
</div>
) : null}
</>
);
}
function CanvasShellChromeAfter({
bottomBar,
inset,
rightBar,
}: CanvasShellChromeAfterProps) {
return (
<>
{hasChromeContent(rightBar) ? (
<div
className="pointer-events-none absolute right-0 z-30 flex"
style={{
bottom: "var(--canvas-shell-safe-bottom)",
right: inset,
top: "var(--canvas-shell-safe-top)",
}}
>
<div className="pointer-events-auto flex">{rightBar}</div>
</div>
) : null}
{hasChromeContent(bottomBar) ? (
<div
className="pointer-events-none absolute inset-x-0 z-30"
style={{ bottom: inset }}
>
<div
className="pointer-events-auto mx-auto w-full max-w-[960px]"
style={{ paddingLeft: inset, paddingRight: inset }}
>
{bottomBar}
</div>
</div>
) : null}
</>
);
}
function renderLegacyCanvasShell(
{
bottomBar: _bottomBar,
bottomSlot,
children,
chromeInset: _chromeInset = 16,
className,
contentPadding: _contentPadding,
leftBar: _leftBar,
leftRail,
rightBar: _rightBar,
rightDock,
style,
topBar,
...props
}: CanvasShellProps,
ref: React.Ref<HTMLElement> | undefined,
) {
return (
<section
className={cn(
"flex min-h-[720px] w-full flex-col overflow-hidden rounded-md border border-border bg-background",
className,
)}
ref={ref}
style={style}
{...props}
>
{topBar}
<div className="grid min-h-0 flex-1 grid-cols-[auto_minmax(0,1fr)_auto] overflow-hidden bg-background">
{leftRail ?? <div />}
<div className="relative min-h-0 min-w-0 overflow-hidden">
{children}
</div>
{rightDock ?? <div />}
</div>
{bottomSlot ? (
<div className="border-t border-border bg-background px-4 py-2">
{bottomSlot}
</div>
) : null}
</section>
);
}
function CanvasShellContent({
content,
contentStyle,
}: {
content: ReactNode;
contentStyle: CSSProperties;
}) {
return (
<div
className="relative z-0 h-full w-full min-h-0 min-w-0"
data-slot="canvas-shell-content"
style={contentStyle}
>
<div className="h-full w-full min-h-0 min-w-0 overflow-hidden">
{content}
</div>
</div>
);
}
function renderFloatingCanvasShell(
{
bottomBar,
bottomSlot,
children,
chromeInset = 16,
className,
contentPadding,
leftBar,
leftRail,
rightBar,
rightDock,
style,
topBar,
...props
}: CanvasShellProps,
ref: React.Ref<HTMLElement> | undefined,
) {
const inset = toInsetValue(chromeInset) ?? "16px";
const resolvedBottomBar = bottomBar ?? bottomSlot;
const resolvedLeftBar = leftBar ?? leftRail;
const resolvedRightBar = rightBar ?? rightDock;
const hasTopBar = hasChromeContent(topBar);
const hasLeftBar = hasChromeContent(resolvedLeftBar);
const hasRightBar = hasChromeContent(resolvedRightBar);
const hasBottomBar = hasChromeContent(resolvedBottomBar);
const safeAreaInsets = getSafeAreaInsets({
chromeInset,
contentPadding,
hasBottomBar,
hasLeftBar,
hasRightBar,
hasTopBar,
});
const mergedStyle = {
...getSafeAreaStyle(safeAreaInsets),
...style,
} satisfies CSSProperties;
const contentStyle = {
paddingBottom: "var(--canvas-shell-safe-bottom)",
paddingLeft: "var(--canvas-shell-safe-left)",
paddingRight: "var(--canvas-shell-safe-right)",
paddingTop: "var(--canvas-shell-safe-top)",
} satisfies CSSProperties;
return (
<section
className={cn(
"relative isolate flex min-h-[720px] w-full overflow-hidden bg-[radial-gradient(circle_at_top,oklch(var(--background)/0.94),oklch(var(--muted)/0.6))]",
className,
)}
ref={ref}
style={mergedStyle}
{...props}
>
<div className="absolute inset-0 bg-[linear-gradient(180deg,oklch(var(--background)/0.94),oklch(var(--background)/0.8))]" />
<CanvasShellChromeBefore
inset={inset}
leftBar={hasLeftBar ? resolvedLeftBar : undefined}
topBar={hasTopBar ? topBar : undefined}
/>
<CanvasShellContent content={children} contentStyle={contentStyle} />
<CanvasShellChromeAfter
bottomBar={hasBottomBar ? resolvedBottomBar : undefined}
inset={inset}
rightBar={hasRightBar ? resolvedRightBar : undefined}
/>
</section>
);
}
const CanvasShell = ({
ref,
...props
}: CanvasShellProps & { ref?: React.Ref<HTMLElement> }) => {
const { bottomBar, chromeInset, contentPadding, leftBar, rightBar } = props;
const hasExplicitChromeInset = Object.prototype.hasOwnProperty.call(
props,
"chromeInset",
);
const usesFloatingChrome =
hasChromeContent(bottomBar) ||
hasChromeContent(leftBar) ||
hasChromeContent(rightBar) ||
contentPadding !== undefined ||
(hasExplicitChromeInset && chromeInset !== undefined);
if (!usesFloatingChrome) {
return renderLegacyCanvasShell(props, ref);
}
return renderFloatingCanvasShell(props, ref);
};
CanvasShell.displayName = "CanvasShell";
export { CanvasShell };
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.