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 panelside

Usage

<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. Takes native to hand it to the platform, and glass to 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.
The panel's regions: a sticky Header holding a Search field, a scrollable Content holding two Groups, and a sticky Footer holding a Cta.
The panel is a column. Only the middle scrolls.
A Group of four Items, each built from an icon, a label, an optional badge and a trailing Action, with a GroupLabel above the group.
A row, and the four slots it is made of.

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

PropTypeDefaultDescription
openbooleanOpen state, when you want to own it. Pair with onOpenChange.
onOpenChange(open: boolean) => voidCalled with the next open state, whether a gesture or you caused it.
defaultOpenbooleanfalseOpen state to start at when you are not controlling it.
modePanelsideMode'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.
widthnumberPanel 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.
docknumber | falsefalseContainer 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.
swipeEnabledbooleantrueSwipe to open, and drag the scene to close. Default true.
swipeFromPanelsideSwipeFrom'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.
edgeWidthnumberEDGE_WIDTHHow 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.
dismissiblebooleantrueTapping the pushed scene, or the Android back button, closes the panel. Default true.
classNamestring

Panelside.Panel

PropTypeDefaultDescription
classNamestring

Panelside.Header

PropTypeDefaultDescription
classNamestring
titlestringRendered as the heading. Omit it and supply your own in children.
actionReactNodeA single element pinned to the trailing end of the title row.

Panelside.Search

PropTypeDefaultDescription
classNamestring
containerClassNamestring

Panelside.Content

PropTypeDefaultDescription
classNamestring
contentContainerClassNamestring

Panelside.Group

PropTypeDefaultDescription
classNamestring

Panelside.GroupLabel

PropTypeDefaultDescription
classNamestring

Panelside.Item

PropTypeDefaultDescription
classNamestring
iconReactNodeLeading element — an icon, an avatar, a coloured dot.
labelstringThe row's text. Truncated to one line, since chat titles run long.
activebooleanfalseMarks the row as the current destination.
badgeReactNodeTrailing count or status. A number or string renders as a pill; anything else renders as given.
disabledbooleanfalse

Panelside.Action

PropTypeDefaultDescription
classNamestring
labelstring'More options'What a screen reader announces. The default control is an unlabelled glyph, so this is the only description it has.

Panelside.Footer

PropTypeDefaultDescription
classNamestring
floatingbooleantrueOverlay 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

PropTypeDefaultDescription
classNamestring
labelstringThe button's text.
iconReactNodeLeading element, usually an icon.
variant'primary' | 'secondary''primary'primary is the filled accent pill; secondary is the quiet one.
nativebooleanfalseRender 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.
glassbooleanfalseDraw the native button in the platform's Liquid Glass material. Requires native, and iOS 26 or later; ignored anywhere else.

Panelside.Scene

PropTypeDefaultDescription
classNamestring
scalenumberSCENE_SCALEHow 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.
radiusnumberSCENE_RADIUSThe corner radius the scene reaches at full travel. Default 32.
dimnumberSCENE_DIMHow far the scene dims at full travel, 0 to 1. Default 0.45.

Panelside.Trigger

PropTypeDefaultDescription
classNamestring
labelstring'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 * radius

scale 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.

Three stages of the transition: closed, dragging and open, with the scene sliding right, scaling to 0.92 and rounding to a 28-point radius as progress runs 0 to 1.
Closed, dragging, open — one value, three properties.

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.

On this page