Canvas Shell

Layout shell for canvas workspaces with top bar, left rail, right dock, and bottom slot regions.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/canvas-shell.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

import { forwardRef } from "react"; 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.ForwardedRef<HTMLElement>, ) { 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 renderFloatingContent( children: 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"> {children} </div> </div> ); } function renderFloatingCanvasShell( { bottomBar, bottomSlot, children, chromeInset = 16, className, contentPadding, leftBar, leftRail, rightBar, rightDock, style, topBar, ...props }: CanvasShellProps, ref: React.ForwardedRef<HTMLElement>, ) { 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} /> {renderFloatingContent(children, contentStyle)} <CanvasShellChromeAfter bottomBar={hasBottomBar ? resolvedBottomBar : undefined} inset={inset} rightBar={hasRightBar ? resolvedRightBar : undefined} /> </section> ); } const CanvasShell = forwardRef<HTMLElement, CanvasShellProps>((props, ref) => { 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 };

Dependencies

  • @vllnt/ui@^0.2.1