Panelside
Navigation panel that moves the app aside instead of covering it.
The navigation pattern an assistant app uses: a button in the corner, and the screen slides across, shrinks, rounds its corners and dims — revealing a panel of destinations and history that was behind it all along. The app never disappears, so going back is a tap on the thing you were just looking at.
It is not a Drawer with different styling. A drawer mounts through a portal, which puts it above the app by construction and leaves the app content somewhere else in the tree, unreachable — there is nothing to push. Panelside renders inline and owns both halves, which is why the part of your app that moves has to be named.
Installation
Panelside ships with the library — no separate install.
import { Panelside, Avatar, BottomSheet, Button, MenuIcon, MessageCircleIcon, PackageIcon, PlusIcon, Select, usePanelside } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add panelsideUsage
<Panelside>
<Panelside.Panel>
<Panelside.Header title="Assistant">
<Panelside.Search value={query} onChangeText={setQuery} />
</Panelside.Header>
<Panelside.Content>
<Panelside.Group>
<Panelside.GroupLabel>Recents</Panelside.GroupLabel>
<Panelside.Item label="Migrating the design tokens" />
</Panelside.Group>
</Panelside.Content>
<Panelside.Footer>
<Panelside.Cta label="New chat" onPress={compose} />
</Panelside.Footer>
</Panelside.Panel>
<Panelside.Scene>
<Panelside.Trigger />
<Conversation />
</Panelside.Scene>
</Panelside>Composition
<Panelside>
<Panelside.Panel>
<Panelside.Header>
<Panelside.Search />
</Panelside.Header>
<Panelside.Content>
<Panelside.Group>
<Panelside.GroupLabel>…</Panelside.GroupLabel>
<Panelside.Item>
<Panelside.Action />
</Panelside.Item>
</Panelside.Group>
</Panelside.Content>
<Panelside.Footer>
<Panelside.Cta />
</Panelside.Footer>
</Panelside.Panel>
<Panelside.Scene>
<Panelside.Trigger />
</Panelside.Scene>
</Panelside>Panelside.Panel— The navigation surface, sitting behind the app. It is a column: a sticky header, a scroller, and a footer pinned to the bottom edge.Panelside.Header— The panel's title row, and anything below it that should not scroll. It clears the status bar itself, because the panel draws behind it.Panelside.Search— A compact filter field. It reports what was typed and nothing else — what counts as a match is yours, since a search that only read titles would be wrong for the first app that indexes message bodies.Panelside.Content— The scrolling middle. It leaves room at the end for a floating footer, measured rather than assumed.Panelside.Group— A run of related rows. Groups are the unit of spacing, so an empty one costs nothing to render conditionally.Panelside.GroupLabel— The heading over a group — “Starred”, “Recents”, “Today”. Announced as a heading.Panelside.Item— One destination or one conversation: a leading icon, a label truncated to a line, an optional badge, and an active state.Panelside.Action— The trailing control on a row — rename, delete, the overflow menu. It takes its target back as touch slop rather than as layout, so it stays small next to a row-sized target.Panelside.Footer— The bar across the bottom of the panel — an account control at one end, the compose button at the other. It floats over the list, with the list dissolving into the panel just above it: a solid bar would cut a strip out of the bottom of the scroller, and a transparent one would let rows show through the controls.Panelside.Cta— The compose button. A pill rather than a rectangle, because it is the one control in the panel that is not a list row. Takesnativeto hand it to the platform, andglassto have the platform draw it in Liquid Glass.Panelside.Scene— Your app. This is the half that moves, and naming it is the whole contract — everything inside it travels, rounds and dims together. It also draws a hairline along its edge as it goes, since two surfaces of the same colour meeting at a corner need something to say they are separate.Panelside.Trigger— Opens and closes the panel. Wraps a control of your own, or draws a plain one. It renders nothing at all when the panel is docked, since there would be nothing to toggle.




Examples
Destinations and history
Two groups, because they are two different things. The first is where you can go and never changes; the second is what you have done and changes every session — so only the second carries a label, and only its rows carry an action.
<Panelside.Content>
<Panelside.Group>
<Panelside.Item icon={<MessageCircleIcon size={20} />} label="Chats" active />
<Panelside.Item icon={<PackageIcon size={20} />} label="Projects" badge={4} />
</Panelside.Group>
<Panelside.Group>
<Panelside.GroupLabel>Recents</Panelside.GroupLabel>
{recents.map((chat) => (
<Panelside.Item key={chat.id} label={chat.title} onPress={() => open(chat.id)}>
<Panelside.Action label={`Options for ${chat.title}`} onPress={() => menu(chat.id)} />
</Panelside.Item>
))}
</Panelside.Group>
</Panelside.Content>Searching the history
Panelside.Search reports what was typed; deciding what matches is yours. Filtering to nothing renders nothing, so the groups can be dropped entirely rather than left as empty headings.
const [query, setQuery] = useState('');
const results = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return chats;
return chats.filter((chat) => chat.title.toLowerCase().includes(needle));
}, [chats, query]);
return (
<Panelside.Panel>
<Panelside.Header title="Assistant">
<Panelside.Search
value={query}
onChangeText={setQuery}
placeholder="Search chats"
/>
</Panelside.Header>
<Panelside.Content>
{results.length > 0 && (
<Panelside.Group>
<Panelside.GroupLabel>Recents</Panelside.GroupLabel>
{results.map((chat) => (
<Panelside.Item key={chat.id} label={chat.title} />
))}
</Panelside.Group>
)}
</Panelside.Content>
</Panelside.Panel>
);A trigger of your own
Given a child, Panelside.Trigger chains the toggle onto that element's own onPress rather than replacing it. A control sitting on the app's bare surface needs a shape to be findable, which is why this is usually a Button rather than an icon on its own.
<Panelside.Scene>
<View className="flex-row items-center gap-2 px-3 pt-3">
<Panelside.Trigger>
<Button size="icon" variant="secondary" className="rounded-full">
<MenuIcon size={20} />
</Button>
</Panelside.Trigger>
<Text size="lg" weight="semibold" className="flex-1">
Migrating the design tokens
</Text>
</View>
<Conversation />
</Panelside.Scene>Moving something with the panel
usePanelside hands back progress as a shared value, so a header of your own can travel with the panel on the UI thread instead of re-rendering once a frame. It reads 0 closed and 1 open, and every value in between during a drag.
function SceneTitle() {
const { progress, toggle } = usePanelside();
const style = useAnimatedStyle(() => ({
opacity: 1 - progress.value,
transform: [{ translateY: progress.value * -8 }],
}));
return (
<Animated.View style={style}>
<Text size="lg" weight="semibold">Migrating the design tokens</Text>
</Animated.View>
);
}A sidebar on a tablet
Past dock, the panel stops being an overlay: it takes a column of the layout, stays open, drops the gesture, narrows to a third of the container, and Panelside.Trigger renders nothing. The number is yours on purpose — set it high enough that what is left over is still a screen. Around 700 is the first width where both halves have room.
<Panelside dock={700}>
<Panelside.Panel>{/* … */}</Panelside.Panel>
<Panelside.Scene>
{/* The trigger disappears on its own above 700pt. */}
<Panelside.Trigger />
<Conversation />
</Panelside.Scene>
</Panelside>Versions
Assistant
The default shape. Swipe in from the leading edge and the screen slides, shrinks and rounds in step with your finger; release under 60 points and it springs back.
<Panelside>
<AssistantPanel />
<Panelside.Scene>
<SceneBar />
<Transcript />
</Panelside.Scene>
</Panelside>Overlay
The same panel, sliding over a screen that stays exactly where it is — for a scene whose content cannot afford to move.
<Panelside mode="overlay">
<AssistantPanel />
<Panelside.Scene>
<SceneBar />
<Transcript />
</Panelside.Scene>
</Panelside>Docked
Past the width you name, the panel is a column of the layout rather than a thing you open: the trigger removes itself, the gesture goes, and the panel narrows to a third of the container — docked, every point it takes is a point the app does not get back.
<Panelside dock={700}>
<AssistantPanel />
<Panelside.Scene>
<SceneBar />
<Transcript />
</Panelside.Scene>
</Panelside>Deeper curve
Scale, radius and dim turned well past their defaults. scale is the one that changes the shape of the thing rather than its finish: below one the screen stops being full height and lifts away from the status bar, which is a different look and not the default for good reason.
<Panelside>
<AssistantPanel />
<Panelside.Scene scale={0.72} radius={44} dim={0.7}>
<SceneBar />
<Transcript />
</Panelside.Scene>
</Panelside>Full chat
A streaming transcript in the scene, anchored and scrolling on its own while the panel holds the history it came from.
<Panelside>
<AssistantPanel />
<Panelside.Scene>
<SceneBar />
<MessageScroller autoScroll className="flex-1">
<MessageScroller.Viewport>
<MessageScroller.Content>
{turns.map((turn) => (
<MessageScroller.Item
key={turn.id}
messageId={turn.id}
scrollAnchor={turn.role === 'user'}
>
<Turn turn={turn} />
</MessageScroller.Item>
))}
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller>
</Panelside.Scene>
</Panelside>Native chat
Panelside has no native prop and cannot have one — the platform toolkits ship a switch, a picker, a sheet and a button, and none of them is a pushing navigation panel. What goes native is what is inside it.
<Panelside>
<AssistantPanel />
<Panelside.Scene>
<SceneBar />
<Select native value={model} onValueChange={setModel}>
<Select.Item value="fast" label="Fast" />
<Select.Item value="balanced" label="Balanced" />
</Select>
<Transcript />
<View className="flex-row justify-between">
<Button native variant="ghost" onPress={() => setAttaching(true)}>Attach</Button>
<Button native onPress={send}>Send</Button>
</View>
<BottomSheet native open={attaching} onOpenChange={setAttaching} snapPoints={['half']}>
<BottomSheet.Content>{/* … */}</BottomSheet.Content>
</BottomSheet>
</Panelside.Scene>
</Panelside>API Reference
Panelside
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Open state, when you want to own it. Pair with onOpenChange. |
onOpenChange | (open: boolean) => void | — | Called with the next open state, whether a gesture or you caused it. |
defaultOpen | boolean | false | Open state to start at when you are not controlling it. |
mode | PanelsideMode | 'push' | How the two layers relate. push moves the scene aside and curves it, which is the point of this component. overlay slides the panel over a scene that stays put — the same navigation, for a screen whose content cannot afford to move. |
width | number | — | Panel width in points. Defaults to 80% of the container capped at 360, and to a third of it capped at 320 once docked — an overlay panel gives the width back when it closes and a docked one keeps it, so they are not the same measurement. The caps are what stop a tablet getting a navigation list with a field of whitespace beside it. |
dock | number | false | false | Container width at or above which the panel stops being an overlay and becomes a permanent sidebar: laid out beside the scene, always open, with the gesture and the trigger switched off. A docked panel also narrows to a third of the container, capped at 320 — docked, every point it takes is a point the app does not get back. Off by default, and deliberately not a guess — a large phone in landscape is wider than a small tablet in portrait, so no single number is right for every app. Set it high enough that what is left over is still a screen: around 700 is the first width where both halves have room. |
swipeEnabled | boolean | true | Swipe to open, and drag the scene to close. Default true. |
swipeFrom | PanelsideSwipeFrom | 'anywhere' | Where a swipe may begin. anywhere is the default and the behaviour this pattern is known for — a sideways drag across the app opens the panel from wherever your thumb already was. edge narrows it to a strip at the leading screen edge, for a scene that has its own use for a horizontal drag: a carousel, a wide table, a chart you can pan. Anything like that under an anywhere panel will fight it, and the panel usually wins. |
edgeWidth | number | EDGE_WIDTH | How wide the leading-edge strip that starts a swipe is, when swipeFrom is edge. Default 48 — wider than the system's own edge gestures, because there is no bezel to feel for. Ignored otherwise. |
dismissible | boolean | true | Tapping the pushed scene, or the Android back button, closes the panel. Default true. |
className | string | — |
Panelside.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Panelside.Header
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
title | string | — | Rendered as the heading. Omit it and supply your own in children. |
action | ReactNode | — | A single element pinned to the trailing end of the title row. |
Panelside.Search
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
containerClassName | string | — |
Panelside.Content
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
contentContainerClassName | string | — |
Panelside.Group
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Panelside.GroupLabel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — |
Panelside.Item
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
icon | ReactNode | — | Leading element — an icon, an avatar, a coloured dot. |
label | string | — | The row's text. Truncated to one line, since chat titles run long. |
active | boolean | false | Marks the row as the current destination. |
badge | ReactNode | — | Trailing count or status. A number or string renders as a pill; anything else renders as given. |
disabled | boolean | false |
Panelside.Action
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
label | string | 'More options' | What a screen reader announces. The default control is an unlabelled glyph, so this is the only description it has. |
Panelside.Footer
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
floating | boolean | true | Overlay the scrolling list instead of taking a row below it. Default true — the list runs the full height of the panel behind it, and Panelside.Content leaves exactly this footer's height of room at the end. |
Panelside.Cta
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
label | string | — | The button's text. |
icon | ReactNode | — | Leading element, usually an icon. |
variant | 'primary' | 'secondary' | 'primary' | primary is the filled accent pill; secondary is the quiet one. |
native | boolean | false | Render the platform's own button instead of the pill. Requires the optional @expo/ui package; without it this prop does nothing. Theme tokens do not apply — the platform draws the button, so className and icon are ignored and it sizes itself to label. |
glass | boolean | false | Draw the native button in the platform's Liquid Glass material. Requires native, and iOS 26 or later; ignored anywhere else. |
Panelside.Scene
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
scale | number | SCENE_SCALE | How small the scene gets at full travel. Default 1 — the screen keeps its full height and stays behind the status bar, and the radius and dim do the work. Below one it shrinks about its centre, which insets it top and bottom as well as at the side. |
radius | number | SCENE_RADIUS | The corner radius the scene reaches at full travel. Default 32. |
dim | number | SCENE_DIM | How far the scene dims at full travel, 0 to 1. Default 0.45. |
Panelside.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
label | string | 'Open navigation panel' | What a screen reader announces. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
How far everything travels
One shared value drives both layers, so a half-finished drag is a real halfway state rather than a blend of two snapshots. At progress p, with panel width W and container width C:
scale = 1 - (1 - scale) * p
translateX = p * (W + 12) - C * (1 - scale) / 2
radius = p * radiusscale defaults to 1, so by default the middle term falls away and the scene simply travels. That is deliberate: a scale is applied about the centre, so anything below one insets the screen at the top and the bottom as well as at the side — it lifts away from the status bar and the home indicator, and the strips of panel that appear above and below it are strips of nothing. Full height, with the corner radius and the dim carrying the effect, is what this pattern actually looks like. Only the screen's content respects the safe area, and it was already doing that on its own.
Set scale below one and the subtraction earns its place. React Native scales about the centre, so a scaled scene has already pulled its leading edge inward before any translation applies — travelling by the panel width alone would leave a gap that grows with the scale, and the panel would look mis-measured.


Where a swipe can start
A single pan opens and closes, and by default it listens across the whole surface: a sideways drag anywhere on the app brings the panel in, from wherever your thumb already was. What keeps a list usable underneath it is the pair of thresholds — the drag gives itself up on 12 points of vertical travel and only claims the touch at 14 horizontal, so anything even slightly vertical resolves as a scroll.
swipeFrom="edge" narrows the closed-state hit area to edgeWidth points at the leading screen edge instead. Reach for it when the scene has its own use for a horizontal drag — a carousel, a wide table, a pannable chart — which would otherwise fight the panel and lose.
<Panelside swipeFrom="edge" edgeWidth={64}>…</Panelside>Either way the restriction is a closed-state one. Open, the whole surface drags: the panel is already out, so there is no app underneath left to compete.
iOS claims the same edge
A native stack turns on a back-swipe from the leading screen edge, and it wins over anything JavaScript puts there — so on a screen inside one, the panel's gesture never sees a touch and only the trigger works. Turn the stack gesture off for that screen:
<Stack.Screen options={{ gestureEnabled: false }} />Give the screen another way back when you do — Panelside is usually the root of a tab, where there was nothing to go back to anyway.
Both halves leave the accessibility tree
Being covered says nothing to a screen reader: without help it reads out a navigation list nobody can see, or the app underneath an open panel. Panelside hides whichever half is not in front, so a swipe through the elements only ever reaches what is actually on the screen.
There is no native prop
The platform toolkits behind the native prop on Button, Select, Switch and BottomSheet ship a handful of controls, and a pushing navigation panel is not one of them — so there is nothing for Panelside to hand rendering to, and a prop that quietly did nothing would be worse than not having one. Everything inside it can still be native. Panelside.Cta takes native and hands the compose button to the platform, and glass with it for the iOS 26 material; in the scene, a native picker, a native sheet behind an attach button and native buttons all work as they do anywhere else. The rows and the search field stay ours, because there is no platform control for either — a panel that went half-native would match neither.
Reduced motion
Every spring here resolves instantly to its target when the system setting is on. The panel still opens and the scene still ends up in the right place — it just does not travel.