The game-day app shell - pinned score header, scrolling work area, and a side panel that becomes a pull-up sheet on phones.
The shell the Schott Classic scorekeeping app runs in: score pinned at the top, controls scrolling in the middle, play-by-play in a sidebar on wide screens and behind a pull-up sheet on phones. Rewritten dependency-free - no portal, no dialog library. The sheet positions inside the layout root rather than the viewport, so the component works full-screen (h-dvh) or embedded in a page, like the demo below.
Install#
npx shadcn@latest add https://benhof.ai/r/scorekeeper-layout.json
import { ScorekeeperLayout } from "@/components/scorekeeper-layout"
<ScorekeeperLayout
className="h-dvh"
header={<ScorePanel ... />}
panel={<PlayByPlay events={events} />}
footer={<GameControls />}
>
<YourScoringControls />
</ScorekeeperLayout>
Props#
| Prop | Type | Default | Description |
|---|---|---|---|
header | ReactNode | - | Pinned at the top - typically a score panel. |
panel | ReactNode | - | Sidebar on md+, pull-up sheet below. Omit for a plain header + content shell. |
panelTitle | string | "Play-by-play" | Labels the sidebar, the pull-up bar, and the sheet. |
footer | ReactNode | - | Pinned action rail that shares space with the phone panel trigger. |
forceMobile | boolean | false | Keeps the phone layout active inside a narrow preview on a wide page. |
compact | boolean | false | Reduces shell padding and control height for one-screen phone interfaces. |
children | ReactNode | - | The scrolling work area. |
Notes#
Give the root a height via className - it's a flex column that manages its own overflow. The sheet is a plain absolutely-positioned panel with a backdrop; it deliberately skips focus-trapping and body scroll-locking to stay dependency-free, so if you need full dialog semantics, swap in your app's sheet primitive around panel.