Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
ContainmentUpdatedFreeWCAG 2.2 AA

Panel

Right (320) + Left/Filter (288) panels — 9 variants.

Preview

Live preview
@oshon-ai/components
Left dock (288 px)Right dock (320 px)

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add panel

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Panel } from '@oshon-ai/components';

export default function Example() {
  return <Panel />;
}

Filter — left, 288px (Figma `Side=Filter`)

tsx
<PanelStage align="left">
      <FilterPanelDemo />
    </PanelStage>

Share Report — right, 320px (Figma `Side=Right`)

tsx
<PanelStage align="right">
      <ShareReportPanelDemo />
    </PanelStage>

Side by side — left filter + right share

tsx
<div
      style={{
        minHeight: '100vh',
        background: PAGE_BG,
        display: 'flex',
        justifyContent: 'space-between',
        gap: 24,
        padding: 'var(--oshon-space-6, 32px)',
        fontFamily: 'var(--oshon-font-family, system-ui)',
      }}
    >
      <div style={{ height: 904 }}>
        <FilterPanelDemo />
      </div>
      <div style={{ flex: 1 }} />
      <div style={{ height: 904 }}>
        <ShareReportPanelDemo />
      </div>
    </div>
tsx
<PanelStage align="center">
      <Panel
        side="right"
        title="Panel title"
        onClose={() => {}}
        footer={
          <>
            <ButtonHug tier="tertiary">Cancel</ButtonHug>
            <ButtonHug tier="secondary">Secondary</ButtonHug>
            <ButtonHug tier="primary">Primary</ButtonHug>
          </>
        }
      >
        <p
          style={{
            margin: 0,
            fontSize: 12,
            lineHeight: '16px',
            color: 'var(--oshon-color-on-surface-muted)',
          }}
        >
          Body content goes here. Sections are separated with a 16px gap;
          the body region scrolls vertically when content overflows.
        </p>
      </Panel>
    </PanelStage>

Size matrix — xs / s / m / l / mobile

size = xs
size = s
size = m
size = l
size = mobile
tsx
<div
      style={{
        minHeight: '100vh',
        background: PAGE_BG,
        padding: 'var(--oshon-space-6, 32px)',
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
        gap: 32,
        fontFamily: 'var(--oshon-font-family, system-ui)',
      }}
    >
      {SIZES.map((size) => (
        <div key={size} style={{ height: 480 }}>
          <div
            style={{
              fontSize: 12,
              fontWeight: 700,
              letterSpacing: '0.4px',
              textTransform: 'uppercase',
              color: 'var(--oshon-color-on-surface-muted)',
              marginBottom: 12,
            }}
          >
            size = {size}
          </div>
          <div style={{ height: 'calc(100% - 24px)' }}>
            <Panel
              size={size}
              side="right"
              title="Panel title"
              onClose={() => {}}
              footer={
                <>
                  <ButtonHug tier="tertiary" size="s">
                    Cancel
                  </ButtonHug>
                  <ButtonHug tier="primary" size="s">
                    Save
                  </ButtonHug>
                </>
              }
            >
              <p
                style={{
                  margin: 0,
                  fontSize: 12,
                  lineHeight: '16px',
                  color: 'var(--oshon-color-on-surface-muted)',
                }}
              >
                Body content scales with size.
              </p>
            </Panel>
          </div>
        </div>
      ))}
    </div>

Side axis — left vs right

side = left (288px default)
side = right (320px default)
tsx
<div
      style={{
        minHeight: '100vh',
        background: PAGE_BG,
        padding: 'var(--oshon-space-6, 32px)',
        display: 'flex',
        gap: 48,
        fontFamily: 'var(--oshon-font-family, system-ui)',
        alignItems: 'flex-start',
      }}
    >
      {SIDES.map((side) => (
        <div key={side} style={{ height: 480 }}>
          <div
            style={{
              fontSize: 12,
              fontWeight: 700,
              letterSpacing: '0.4px',
              textTransform: 'uppercase',
              color: 'var(--oshon-color-on-surface-muted)',
              marginBottom: 12,
            }}
          >
            side = {side} ({side === 'left' ? '288px' : '320px'} default)
          </div>
          <Panel
            side={side}
            title={side === 'left' ? 'Filters' : 'Details'}
            onClose={() => {}}
            footer={
              <>
                <ButtonHug tier="tertiary">Cancel</ButtonHug>
                <ButtonHug tier="primary">Save</ButtonHug>
              </>
            }
          >
            <p
              style={{
                margin: 0,
                fontSize: 12,
                lineHeight: '16px',
                color: 'var(--oshon-color-on-surface-muted)',
              }}
            >
              The visible edge — border + rounded corner — sits opposite
              the docking edge so it always meets the page content.
            </p>
          </Panel>
        </div>
      ))}
    </div>

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
title*ReactNodePanel title. Canonical usage: short string rendered as `<h2>`.
asenumasideRoot element. Defaults to `'aside'` (complementary landmark). Drop to `'div'` when Panel is used inside a structure that already owns the landmark.
childrenReactNodeBody content. Scrolls vertically when it overflows.
classNamestringAdditional classes merged onto the root element.
closeIconReactNodeOverride for the close glyph. Defaults to a 16×16 X. The icon renders inside a 20×20 click-target shell so the hit area stays consistent across themes.
closeLabelstringClosei18n-friendly aria-label for the close button. Default `'Close'`.
footerReactNodeOptional footer cluster — typically a button trio. Right-aligned with an 8px gap. Omit to render the panel without a footer.
headingLevelenum2HTML heading level for the title. Defaults to `2`. PageHeader is usually the h1 above the panel — drop deeper as the page outline requires.
onClose((event: MouseEvent<HTMLButtonElement, MouseEvent>) => void)Optional callback fired when the close button is clicked. When omitted, the close button does not render.
sideenumrightDocking edge — see {@link PanelSide}.
sizeenummVisual size. Default `'m'` (Figma parity).
slotClassNamePanelSlotClassNamesPer-slot className overrides — see {@link PanelSlotClassNames}.
widthstring | numberWidth override. Defaults to the size's natural width: 288 for `side="left"` size m, 320 for `side="right"` size m. Pass a number (px) for fixed widths or a string (e.g. `"100%"`) for fluid widths. `size="mobile"` ignores this and is always 100%.

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Panel
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

title

Panel title. Accepts a ReactNode but the canonical usage is a short string rendered as an `<h2>`. Font is Lato Bold; size scales per the five-size axis (14/18 at xs through 18/22 at l).

children

Body content — the scrollable region beneath the header. Padded 16px (Figma `m`) and uses a 16px gap between direct children. Overflows vertically with a scroll bar; horizontally clipped so chips and chips-under-fields never push the panel wider than its set width.

footer

Optional trailing action cluster — typically a `Cancel / secondary / primary` button trio. Right-aligned with an 8px gap and 12/16 padding. Omit to render the panel without a footer.

close

Internal slot — rendered automatically when `onClose` is provided. Hosts the 16×16 X glyph inside a 20×20 click target. Override the glyph via `closeIcon` (e.g. RTL flips, custom icons). Override the aria-label via `closeLabel` for i18n.

Keyboard

Renders an `<aside>` complementary landmark by default (overridable via `as`). Tab order: any interactive element inside the body → close button (when `onClose` is provided) → any interactive element inside `footer`, in DOM order. The close button is a native `<button type="button">` with `aria-label` = `closeLabel` (default "Close"). Title renders as the heading level chosen via `headingLevel` (default `h2`, since PageHeader normally owns `h1`). Body region scrolls vertically when its content overflows.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-24

Do / Don't

✓ Do

Basic right-docked panel
<Panel side="right" title="Details" onClose={() => setOpen(false)} />
Filter panel — left dock, 288 wide, button trio
<Panel
  side="left"
  title="Filters"
  onClose={() => setOpen(false)}
  footer={<>
    <ButtonHug tier="tertiary">Cancel</ButtonHug>
    <ButtonHug tier="secondary">Clear All</ButtonHug>
    <ButtonHug tier="primary">Save</ButtonHug>
  </>}
>
  <Section label="Saved filter option">…</Section>
  <AccordionList>…</AccordionList>
</Panel>
Share panel — right dock, 320 wide, form sections
<Panel
  side="right"
  title="Share Report"
  onClose={() => setOpen(false)}
  footer={<>
    <ButtonHug tier="tertiary">Cancel</ButtonHug>
    <ButtonHug tier="secondary">Preview</ButtonHug>
    <ButtonHug tier="primary">Send</ButtonHug>
  </>}
>
  <Section label="Include details" hint="* Required">…</Section>
  <Divider />
  <Section label="Email details">…</Section>
</Panel>
Mobile — full-width stacked
<Panel size="mobile" side="right" title="Filters" onClose={onClose}>…</Panel>

✗ Don't

Hardcoding a viewport-relative height
<Panel className="h-screen" title="Filters" />

Panel is fluid-height — it takes the height of its parent. Pin a parent element to `100vh` (or use `Drawer` for overlay docking) so Panel can stay layout-agnostic. Hardcoding a viewport height inside Panel breaks composition with PageHeader / GlobalToolbar that already consume their own slice.

Putting page navigation inside Panel
<Panel side="left" title="Menu"><NavList … /></Panel>

Panel is for filters, details, drafts, share dialogs — surfaces that augment the current page. App navigation belongs in `SideNav`, which renders the navigation landmark, supports L1/L2/L3 cascade, and shares state with `GlobalToolbar`.

Two open panels on the same edge
<><Panel side="right" … /><Panel side="right" … /></>

Two right-docked panels collide on the visible edge — the borders and rounded corners stack incorrectly and tab order ambiguates between them. Panels are mutually exclusive per edge; switch one to `side="left"` or replace the second with `Modal`.

Reaching past the body region
<Panel title="Filters">…<button className="fixed top-[8px] right-[8px]">Apply</button></Panel>

Panel reserves the header right-edge for the close button and the footer for the action cluster. Floating an absolute / fixed action over those regions breaks the focus order and the close affordance. Use the `footer` prop instead.

Design rationale

Panel's job is the chrome — header, body, footer — not the content of any particular page. The Figma source ships three named variants (Side=Right, Side=Filter, Side=FilterOverflow) but they all share the same internal contract: a 56px header with a Lato Bold 16/18 title and a 20×20 close button, a body that scrolls and uses a 16px gap between sections (Figma's 2026 update note explicitly calls out the 16/24 change), and an optional 12/16 footer that right-aligns its children. The variants only differ in docking edge and width — left at 288, right at 320 — so this component covers all three with two props (`side` + `width`). The five-size axis scales width / header / padding / title font symmetrically: xs (240/48) for compact desktop sidebars, m (288 / 320) for the canonical Figma case, l (360 / 64) for breathing room, and `mobile` for full-width stacking on small viewports. The visible-edge mirror (left → border-r + rounded-tr; right → border-l + rounded-tl) means the panel always shows its rounded corner where it meets the page content, never against the viewport edge. Per-slot className overrides + the as= override keep the layout decisions reachable from props so callers never need to fork the component.