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.

Report a bug

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.json

Source

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.

Dependencies

  • @vllnt/ui@^0.3.0