Shell

The app frame, with panes that open, close and resize

Shell is the frame of an application. It places a header, navigation, the main content, an inspector and a bottom panel on a CSS grid. Each pane holds its own open state, follows the window when nobody has touched it, and becomes an overlay where there is no room for it.

Installation

npm install @kushagradhawan/kookie-ui

Usage

import { Shell } from '@kushagradhawan/kookie-ui';
 
export function App() {
  return (
    <Shell.Root>
      <Shell.Sidebar>
        <nav>Navigation</nav>
      </Shell.Sidebar>
      <Shell.Content>Main content</Shell.Content>
    </Shell.Root>
  );
}

On a window 768px or wider the sidebar sits in the frame and is open. Below 768px it is an overlay and is closed until a Shell.Trigger opens it.

Anatomy

<Shell.Root>
  <Shell.Header>{/* the top row */}</Shell.Header>
  <Shell.Rail>{/* a narrow column of icons */}</Shell.Rail>
  <Shell.Panel>{/* the navigation the rail opens */}</Shell.Panel>
  <Shell.Sidebar>{/* one navigation column, in place of Rail and Panel */}</Shell.Sidebar>
  <Shell.Content>{/* the work area */}</Shell.Content>
  <Shell.Inspector>{/* details at the inline end */}</Shell.Inspector>
  <Shell.Bottom>{/* a panel along the bottom */}</Shell.Bottom>
  <Shell.Trigger target="sidebar">{/* a button that drives a pane */}</Shell.Trigger>
</Shell.Root>

Every part is optional except Shell.Content. The Root renders every child in the order you write it, and each part takes its own place on the Root's grid. A part must therefore be a direct child of Shell.Root, or sit in a wrapper with display: contents; inside any other wrapper the wrapper takes the grid cell. Any other child is a grid item too: give it a place (grid-area or position), or it takes the first free cell.

A Panel with no presentation of its own takes the Rail's. In the server HTML it does so only when it comes after the Rail in the same parent; otherwise it follows the Rail once the page is hydrated.

Props

Root

PropTypeDefaultDescription
height'full' | 'auto' | string | number'full'The height of the frame. 'full' is 100dvh, with 100vh where dvh is not supported.
childrenReactNodeThe parts, and anything else.
PropTypeDefaultDescription
heightnumber64The height of the row, in pixels.
PropTypeDefaultDescription
state'collapsed' | 'thin' | 'expanded'The state, when you control it.
defaultStateSidebarMode | Responsive<SidebarMode>The starting state. With no value the sidebar is untouched and follows the window.
onStateChange(state, { reason }) => voidCalled when a user changes the state. reason is 'toggle' or 'dismiss'.
presentation'fixed' | 'overlay' | 'stacked' | Responsive<…>{ initial: 'overlay', sm: 'fixed' }How the sidebar sits in the frame.
toggleModes'both' | 'single''both''both' goes collapsed, thin, expanded. 'single' goes between collapsed and one open state.
expandedSizenumber288The open width, in pixels.
thinSizenumber64The thin width, in pixels.
insetbooleanLifts the pane off the frame. See Inset.

The sidebar also takes the resize props.

Rail

PropTypeDefaultDescription
openbooleanThe open state, when you control it.
defaultOpenboolean | Responsive<boolean>The starting state. With no value the rail is untouched: open where it is in the frame, closed where it is an overlay.
onOpenChange(open, { reason }) => voidCalled when a user opens or closes the rail.
presentation'fixed' | 'overlay' | 'stacked' | Responsive<…>'fixed'How the rail sits in the frame.
expandedSizenumber64The width, in pixels.
insetbooleanLifts the pane off the frame.

Panel

PropTypeDefaultDescription
openbooleanThe open state, when you control it.
defaultOpenboolean | Responsive<boolean>The starting state. With no value the panel is closed.
onOpenChange(open, { reason }) => voidCalled when a user opens or closes the panel.
presentation'fixed' | 'overlay' | 'stacked' | Responsive<…>the Rail'sWith no value, the panel takes the rail's presentation.
expandedSizenumber288The open width, in pixels.
insetbooleanLifts the pane off the frame.

The panel also takes the resize props.

Content

PropTypeDefaultDescription
insetbooleanLifts the pane off the frame.

Inspector

PropTypeDefaultDescription
openbooleanThe open state, when you control it.
defaultOpenboolean | Responsive<boolean>The starting state. With no value the inspector is closed.
onOpenChange(open, { reason }) => voidCalled when a user opens or closes the inspector.
presentation'fixed' | 'overlay' | 'stacked' | Responsive<…>{ initial: 'overlay', sm: 'fixed' }How the inspector sits in the frame.
expandedSizenumber320The open width, in pixels.
insetbooleanLifts the pane off the frame.

The inspector also takes the resize props.

Bottom

PropTypeDefaultDescription
openbooleanThe open state, when you control it.
defaultOpenboolean | Responsive<boolean>The starting state. With no value the panel is closed.
onOpenChange(open, { reason }) => voidCalled when a user opens or closes the panel.
presentation'fixed' | 'overlay' | 'stacked' | Responsive<…>'fixed'How the panel sits in the frame.
expandedSizenumber200The open height, in pixels.
insetbooleanLifts the pane off the frame.

The bottom panel also takes the resize props.

Trigger

Shell.Trigger is a <button>. Wrap it with IconButton or Button through asChild to style it.

PropTypeDefaultDescription
target'rail' | 'panel' | 'sidebar' | 'inspector' | 'bottom'The pane it drives.
action'toggle' | 'expand' | 'collapse''toggle'What a press does.

The trigger renders type="button", aria-controls (the pane's id) and aria-expanded. Your onClick runs first. Call event.preventDefault() in it to cancel the trigger's action.

Resize

Panel, Sidebar, Inspector and Bottom take these props.

PropTypeDefaultDescription
resizablebooleanfalseShows a handle on the pane's inner edge.
sizenumber | stringThe size, when you control it.
defaultSizenumber | stringThe starting size.
onSizeChange(size, { reason }) => voidCalled with the new size. reason is 'init', 'resize' or 'controlled'.
sizeUpdate'throttle' | 'debounce'Rate-limits onSizeChange across repeated resize ends, such as key presses on the handle. A drag calls onSizeChange once, when it ends. Use onResize for live sizes.
sizeUpdateMsnumber50The interval for sizeUpdate.
minSizenumberper paneThe smallest size, in pixels.
maxSizenumberper paneThe largest size, in pixels. A drag or a key press also stops before the handle leaves the frame.
collapsiblebooleantrueA double-click on the handle closes the pane. Reopen it with a Trigger.
onResizeStart(size) => voidCalled when a drag starts.
onResize(size) => voidCalled during a drag, at most once per animation frame, and once more with the final size.
onResizeEnd(size) => voidCalled when a drag ends.

Presentation

presentation says how a pane sits in the frame. It takes one value or a responsive object.

Fixed

The pane takes its own column or row, and the content is narrower by its size.

Overlay

The pane covers the frame. A scrim covers everything else, the rest of the frame is inert, and focus moves into the pane. Escape or a press on the scrim closes it, and focus goes back where it was. An overlay that nobody has opened stays closed.

How far an overlay reaches depends on the Root's height:

  • 'full' (the default): the overlay covers the window, and the page does not scroll while it is open.
  • 'auto': the frame grows with its content and the page scrolls, so the overlay and the scrim are placed on the window.
  • A number or a length: the Shell sits inside a page, and the overlay and the scrim cover only the Shell's box. The page still scrolls.

Stacked

The pane sits over the content without a scrim and without taking space from it.

States

Untouched

A pane with no open, defaultOpen, state or defaultState is untouched. It renders data-state="auto", and the stylesheet shows or hides it at each breakpoint:

  • Rail and Sidebar are open where they are in the frame and closed where they are an overlay.
  • Panel, Inspector and Bottom are closed.

A responsive defaultOpen or defaultState also leaves the pane untouched, with your object as the answer at each breakpoint:

<Shell.Inspector defaultOpen={{ initial: false, lg: true }}>…</Shell.Inspector>

The server HTML already carries the answer for every breakpoint, so the first paint is right before any script runs.

Touched

When a user opens or closes a pane, it keeps that state at every width. A single-value defaultOpen or defaultState starts the pane in that state in the same way.

Controlled

Pass open (or state on the Sidebar) with its change handler. The pane shows what you pass. Do not pass both the controlled prop and its default.

Callbacks

onOpenChange and onStateChange are called only when a user acts. reason is:

  • 'toggle' for a Trigger, a double-click on the handle, or a drag past collapseThreshold.
  • 'dismiss' for Escape or a press on the scrim.

They are not called on mount, and not called when the window crosses a breakpoint. The deprecated onExpand and onCollapse follow the same rule, and also fire when a controlled open or state changes whether the pane is shown.

thin shows the sidebar at thinSize. With toggleModes="both" a trigger goes collapsed, thin, expanded. With toggleModes="single" it goes between collapsed and the sidebar's open state (thin if defaultState is "thin", else expanded).

Examples

Header and Sidebar

<Shell.Root>
  <Shell.Header>
    <Flex align="center" gap="2" px="3" height="100%">
      <IconButton variant="ghost" color="gray" aria-label="Toggle navigation" asChild>
        <Shell.Trigger target="sidebar">
          <HugeiconsIcon icon={SidebarLeft01Icon} strokeWidth={1.75} />
        </Shell.Trigger>
      </IconButton>
      <Text weight="medium">Projects</Text>
    </Flex>
  </Shell.Header>
  <Shell.Sidebar>
    <nav>Navigation</nav>
  </Shell.Sidebar>
  <Shell.Content>Main content</Shell.Content>
</Shell.Root>

Rail and Panel

<Shell.Root>
  <Shell.Rail>
    <IconButton variant="ghost" color="gray" aria-label="Toggle panel" asChild>
      <Shell.Trigger target="panel">
        <HugeiconsIcon icon={SidebarLeft01Icon} strokeWidth={1.75} />
      </Shell.Trigger>
    </IconButton>
  </Shell.Rail>
  <Shell.Panel defaultOpen={{ initial: false, md: true }}>
    <nav>Navigation</nav>
  </Shell.Panel>
  <Shell.Content>Main content</Shell.Content>
</Shell.Root>

The rail and the panel do not drive each other. To close the panel when the rail closes, write it:

const [railOpen, setRailOpen] = React.useState(true);
const [panelOpen, setPanelOpen] = React.useState(true);
 
<Shell.Rail
  open={railOpen}
  onOpenChange={(open) => {
    setRailOpen(open);
    if (!open) setPanelOpen(false);
  }}
/>
<Shell.Panel open={panelOpen} onOpenChange={setPanelOpen} />

Resizable Inspector

const [width, setWidth] = React.useState(320);
 
<Shell.Inspector
  resizable
  size={width}
  onSizeChange={setWidth}
  minSize={240}
  maxSize={480}
>
  Properties
</Shell.Inspector>

Keep the size in your own state to save it between visits.

Controlled Sidebar

const [state, setState] = React.useState<'collapsed' | 'thin' | 'expanded'>('expanded');
 
<Shell.Sidebar state={state} onStateChange={setState}>
  <nav>Navigation</nav>
</Shell.Sidebar>

Inset

inset lifts a pane off the frame. The pane gets a margin, rounded corners and a shadow, and the frame paints a gray backdrop behind the middle row. Two inset panes side by side share one gap.

<Shell.Root>
  <Shell.Sidebar inset>…</Shell.Sidebar>
  <Shell.Content inset>…</Shell.Content>
</Shell.Root>

Change the gap on the Root:

.rt-ShellRoot {
  --shell-inset-gap: var(--space-3);
}

Responsive

presentation, defaultOpen and defaultState take responsive objects. A breakpoint takes the value of the largest key at or below it.

BreakpointWidth
initialbelow 520px
xs520px
sm768px
md1024px
lg1280px
xl1640px

The values are written to the pane as data-presentation-<bp> and data-auto-<bp>, and the stylesheet reads them at these widths.

Accessibility

  • Shell.Header renders <header> and Shell.Content renders <main>.
  • Shell.Trigger sets aria-controls and aria-expanded.
  • An open overlay makes the rest of the frame inert, moves focus into the pane, and returns focus when the pane closes.
  • Escape closes open overlays. It is handled as the key bubbles up to the Root, after the panes' own handlers: a component inside a pane that answers Escape itself, and calls preventDefault() or stopPropagation(), keeps the pane open. A dialog or menu opened from a pane closes first.
  • The resize handle is a focusable role="separator" with aria-controls, aria-valuenow, aria-valuemin and aria-valuemax. The arrow keys move it by 8px (32px with Shift), and Home and End go to the limits. It shows a focus line on :focus-visible.

Changelog

0.8.0

  • The Shell is rebuilt on kookie-ui-v2's model. See the package changelog for the full list.
  • Untouched panes follow the window through the stylesheet. Callbacks fire only when a user acts.
  • Sidebar and Inspector are overlays below 768px.
  • The Rail and the Panel do not drive each other.
  • An overlay is the same element as the pane, not a portalled sheet.
  • The resize handle is role="separator".
  • No motion.
  • Deprecated: Shell.Left, target="left", peekOnHover, snapPoints, snapTolerance, collapseThreshold, paneId, persistence, a responsive controlled open or state, useShell() and useResponsivePresentation().

0.7.1

  • Bug fixes: Root typing, Panel onExpand/onCollapse, Trigger type and aria-controls, Bottom arrow keys, the 100dvh height, the handle's focus line, and the height prop on panes.

© 2026 Kushagra Dhawan. Licensed under MIT. GitHub.

Theme

Accent color

Gray color

Appearance

Radius

Scaling

Panel background