panelui-native
v0.40.0
Published
High-performance React Native UI components for Expo. Semantic design tokens, powered by Uniwind (Tailwind v4) and Reanimated.
Maintainers
Readme
PanelUI — React Native UI components for Expo, styled with Tailwind CSS
PanelUI (panelui-native) is an accessible, high-performance React Native component
library for Expo apps. 79 typed components — buttons, bottom sheets, dialogs, selects,
toasts, forms — styled with Tailwind CSS v4 and animated on the UI thread with Reanimated.
Zero native code, so it runs in Expo Go.
- ⚡ Tailwind CSS for React Native via Uniwind — no Babel transform, roughly 2.4–3× faster styling than NativeWind.
- 🧵 60fps animations on the UI thread with Reanimated 4. Press feedback, switches, sheets, dialogs and tabs never touch the JS thread.
- 🎨 Six built-in themes — Panel, Moon and Grass, each in light and dark. A theme sets radius as well as colour, so switching one restyles the shape of the UI too.
- 🌗 Native dark mode. Theme switching is applied natively by Uniwind, without re-rendering your component tree.
- ♿ Accessible by default — every interactive component wires up
accessibilityRole, state and labels. - 📦 TypeScript, tree-shakeable, zero native modules — works in Expo Go, no prebuild needed.
Quick start
Needs an Expo SDK 57+ app and Node 20+. No Xcode, no Android Studio, no prebuild — PanelUI has
no native modules, so it runs in Expo Go. No app yet? npx create-expo-app@latest my-app.
Five steps. The full walkthrough, with a fix for every error people hit, is at panelui.dev/docs/installation.
1. Install the packages
npx expo install panelui-native uniwind tailwindcss @react-native-masked-view/masked-view expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-workletsInstall all of them, including the ones you think you don't need. Metro resolves every import in
the library when it builds your bundle, so leaving one out fails the first bundle with
Unable to resolve module … — for a component you may never use. npx expo install (rather than
npm install) picks the versions that match your SDK.
Optional, each behind a guarded import: expo-haptics (the haptics prop), expo-blur (blurred
overlay backdrops), expo-clipboard (useCopyToClipboard), react-native-keyboard-controller
(keyboard avoidance on Android), expo-file-system + react-native-view-shot (Signature export),
@expo/ui (native platform controls).
2. Add metro.config.js
In the root of your project, next to package.json — not in app/, not in src/. A Metro
config anywhere else is silently ignored and none of your classes will do anything. A fresh Expo
app has no such file; npx expo customize metro.config.js writes one.
// metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { withUniwindConfig } = require('uniwind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withUniwindConfig(config, {
cssEntryFile: './src/global.css',
dtsFile: './uniwind-types.d.ts',
// Only needed to switch to the Moon or Grass themes at runtime.
extraThemes: ['moon', 'moon-dark', 'grass', 'grass-dark'],
});cssEntryFile and dtsFile are relative to this file. ./src/global.css is where
create-expo-app puts the CSS — if yours is at the project root, change it to './global.css'.
Nothing validates that path. Point it at a file that does not exist and Metro still bundles, the
app still launches, and not one class resolves — no flex-1, so views collapse and you get a blank
screen with Uniwind - We couldn't find your variable --color-background.
3. Add the imports to global.css
Look for this file before you create one — an app made by create-expo-app already has
src/global.css. Add these lines at the top of it and leave the rest below. A second CSS file at
the project root gives you two entries, only one of which is compiled, and nothing gets styled.
/* src/global.css */
@import 'tailwindcss';
@import 'uniwind';
@import 'panelui-native/theme.css';
@source '../node_modules/panelui-native/src';@source is relative to the CSS file, and has to land on node_modules/panelui-native/src —
hence the ../ above, from src/. From the project root it is './node_modules/…', from
src/styles/ it is '../../node_modules/…'. Get it wrong and your own classes work while
PanelUI's components come out unstyled. Whichever file you used is what cssEntryFile must name.
4. Import the CSS and add the provider
At the top of your app's entry file. The default template uses Expo Router with routes under
src/, so there is usually no App.tsx — the entry is src/app/_layout.tsx, and it already
returns a navigation ThemeProvider to wrap rather than replace:
// src/app/_layout.tsx
import '../global.css';
import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router';
import { useColorScheme } from 'react-native';
import { PanelUIProvider } from 'panelui-native';
import AppTabs from '@/components/app-tabs';
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<PanelUIProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<AppTabs />
</ThemeProvider>
</PanelUIProvider>
);
}That navigation theme paints over PanelUI's background once you switch themes — see Using Expo Router? below for the version fed from live tokens.
PanelUIProvider sets up the gesture root, the themed page background, the portal host used by
overlays, and the toast viewport. One at the root is enough — don't nest a second.
You do not need a babel.config.js; Expo's default preset already wires the worklets plugin
Reanimated needs.
5. Restart
npx expo start --clearMetro reads metro.config.js, global.css and extraThemes once, at startup. After changing any
of them, stop the dev server and start it again — --clear on a running one is not enough.
import { Button, Card } from 'panelui-native';
<Card>
<Card.Header>
<Card.Title>It works</Card.Title>
<Card.Description>PanelUI is installed and themed.</Card.Description>
</Card.Header>
<Card.Footer>
<Button>Deploy</Button>
</Card.Footer>
</Card>;A themed background, a card with a border and radius, and a button that dips when pressed means you are done. Unstyled text on a white screen means the styles are not reaching the bundle — see Troubleshooting.
Components
| Component | What it does |
| --- | --- |
| Accordion | Collapsible sections with single or multiple selection |
| Alert | Status message with a built-in icon |
| AreaChart | Filled bands over time, stacked or overlaid |
| Attachment | File row with upload states, built on Item |
| Avatar | User image with an initials fallback and an optional badge overlay |
| Badge | Compact status label, dot, or notification count |
| BarChart | Categories compared by length, grouped or stacked |
| BottomSheet | Draggable sheet anchored to the bottom of the screen |
| Breadcrumb | The trail of links back up the hierarchy to the current page |
| Button | Pressable action with variants, sizes, loading state and icon slots |
| Calendar | A month of days, for picking one, several, or a range |
| Card | Content surface with header, body and footer |
| Carousel | A run of slides, one at a time, dragged with a finger |
| Checkbox | Animated checkbox, as a row or a selectable card |
| Chip | Interactive pill — a filter, a tag, or a removable token |
| CodeBlock | Syntax-highlighted code with a header and a copy button |
| Combobox | A text field that filters a list of options as you type |
| DatePicker | A calendar behind a button |
| Dialog | Modal dialog with a backdrop and footer actions |
| Direction | Reading direction for everything below it |
| Drawer | Panel that slides in from an edge of the screen |
| EmptyState | Placeholder for a list or screen with no content |
| Field | Layout and validation-state kit a form control composes into |
| Flow | Pan-and-zoom canvas of draggable nodes joined by animated edges |
| Form | Form state — values, validation and submission — with no form library underneath |
| Frame | Widget shell — a card of rows sitting in a titled tray |
| HeatmapChart | Contribution grid with a themed colour ramp and a readout |
| Input | Text field with label, description and error message |
| InputGroup | Input with leading and trailing decorators |
| Item | Row of media, text and actions for lists and settings |
| Label | Form field label with required, invalid and disabled states |
| LineChart | Animated time series, drawn on the UI thread |
| Loader | Nine loading animations behind one variant prop |
| Map | Vector map whose basemap is drawn from your theme tokens |
| Marker | Inline note between conversation turns |
| Menu | The list of things you can do to something |
| Message | Chat turn with avatar, bubble, header and footer |
| MessageScroller | Scroll behaviour a chat transcript needs |
| NumberInput | Numeric field stepped by buttons or typed by hand |
| OtpInput | One-time-code field drawn as a row of separate cells |
| Pagination | Paged navigation over a long result set |
| Panelside | Collapsible side panel with its own search, groups and scenes |
| Plan | What an agent intends to do, before it does it |
| Popover | Panel anchored to the element that opened it |
| Post | Social card — author, body, media and the counts underneath |
| Progress | Determinate and indeterminate progress bar |
| RadioGroup | Single-select list of options |
| Rating | A row of stars to read or set a score |
| Reasoning | An agent’s thinking, collapsed until you want it |
| Response | Streamed markdown rendered as native components |
| RingChart | Concentric arcs, each measured against its own target |
| ScrollCanvas | Image frame whose contents move as you scroll |
| ScrollFade | Fades the edges of a scroll container |
| ScrollText | Text that resolves word by word as you scroll |
| SectionRail | Floating section navigator for a long screen |
| Select | Picker shown in a bottom sheet, expanded in place, or floating over the page, with an optional filter |
| Separator | Horizontal or vertical rule between content, optionally labelled |
| Shimmer | Animated highlight sweeping across content |
| Signature | Sign with a finger, and get the result back out as SVG or PNG |
| Skeleton | Shimmer placeholder for loading content |
| Slider | Pick a value by dragging a thumb along a track |
| Soundwave | What a voice looks like while an app listens |
| Sources | The references behind an answer, behind a disclosure |
| Spinner | Indeterminate loading indicator |
| Steps | Stepper for multi-step flows |
| Surface | Elevated container with a variant ladder |
| Swipe | A row that slides aside to reveal the things you can do to it |
| Switch | Animated on/off toggle |
| Table | Rows and columns that stay lined up, with sortable headers |
| Tabs | Segmented navigation with an animated indicator |
| Task | A step an agent is working through, with its files |
| Textarea | Multi-line text field that can grow with its content |
| ThinkingOrb | Dotted orb saying which kind of busy an agent is |
| Timeline | Vertical sequence of events |
| TimePicker | Pick a time — as a wheel, a ruler, or a pair of fields |
| Toast | Transient notification queue with swipe to dismiss |
| ToggleButton | A button that stays down, on its own or in a group |
| Tooltip | A small label that names the control under your finger |
| Typography | Semantic text presets |
Plus PanelUIProvider, Portal, AnimatedPressable, KeyboardAvoider, ScrollProgress,
Text, a set of SVG icons (with brand marks for Google, Facebook and Apple), and the cn
class-merging helper.
Hooks: useTheme, useThemeMode, useToast, useCopyToClipboard, useDisclosure,
useBreakpoint, useKeyboard, useKeyboardAvoidance, useScrollSections,
useRevealProgress, useDebouncedValue, usePrevious.
Icons inside a coloured surface inherit a readable colour automatically — Button provides the
foreground its variant reads against, so an icon in startContent follows the theme without a
hardcoded hex. Wrap your own surfaces in IconColorProvider to do the same.
Every component takes a className, so anything can be restyled with Tailwind utilities:
<Button className="w-full rounded-full" labelClassName="uppercase">
Continue
</Button>Toasts
import { useToast } from 'panelui-native';
const { toast } = useToast();
toast.show('Link copied');
toast.show({
variant: 'success',
label: 'Deployment complete',
description: 'panelui.dev is live on production.',
actionLabel: 'View',
onActionPress: ({ hide }) => hide(),
});The queue lives outside React, so toast.show() also works from API clients and other
non-component code.
Theming and dark mode
PanelUI ships three theme families, each in light and dark: Panel (light / dark),
Moon (moon / moon-dark) and Grass (grass / grass-dark). A family sets its own
radius scale as well as its own palette — Panel is the default, Moon is sharp and
monochrome, Grass is soft and green.
import { useTheme, useThemeMode, PANEL_THEMES } from 'panelui-native';
// Switch to a specific theme
const { theme, setTheme } = useTheme();
setTheme('moon-dark');
setTheme('system'); // follow the device
// Or treat brand and light/dark as separate axes
const { family, mode, setFamily, toggleMode } = useThemeMode();
toggleMode(); // dark ↔ light, staying in the current brand
setFamily('grass'); // switch family, staying in the current modeTokens are plain CSS variables, so you can override any of them in your own global.css:
@import 'panelui-native/theme.css';
@layer theme {
:root {
@variant dark {
--color-primary: #818cf8;
}
}
}Using Expo Router? Read this
React Navigation paints its own theme background over every screen, and it defaults to an opaque
light grey — which sits on top of PanelUIProvider's background and makes theme switching look
like it does nothing. Feed it the live PanelUI tokens:
import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from 'expo-router';
import { useCSSVariable } from 'uniwind';
import { useThemeMode } from 'panelui-native';
function ThemedNavigation() {
const { mode } = useThemeMode();
const [background, card, text, border] = useCSSVariable([
'--color-background', '--color-card', '--color-foreground', '--color-border',
]) as (string | undefined)[];
const base = mode === 'dark' ? DarkTheme : DefaultTheme;
return (
<ThemeProvider value={{ ...base, dark: mode === 'dark',
colors: { ...base.colors, background, card, text, border } }}>
<Stack />
</ThemeProvider>
);
}useCSSVariable subscribes to Uniwind's theme changes, so this re-runs on every switch — including
the named themes, which the OS Appearance API knows nothing about. For the same reason, drive
<StatusBar> from mode rather than style="auto".
FAQ
The first bundle fails with Unable to resolve module …
A required package is missing. Run the install command again — all of it, not just the package named in the error. Metro resolves every import in the library, so this happens even for components you never use.
Cannot use @variant with unknown variant: moon when building
Fixed in 0.22.5 — upgrade with npx expo install panelui-native. Before that release the Moon and
Grass token blocks leaned on variants that only existed in the artifact Uniwind generates from your
Metro config, so the dev server worked while npx expo export and EAS builds failed.
None of my classes do anything
In order of likelihood: metro.config.js is not in the project root; it does not wrap the config
in withUniwindConfig; cssEntryFile does not point at your CSS file; import './global.css' is
missing from the entry file; or the dev server was running when you changed one of those. Full
list at Troubleshooting.
How is PanelUI different from NativeWind?
NativeWind is a styling engine; PanelUI is a component library. PanelUI is built on Uniwind, a faster Tailwind v4 engine for React Native that skips the Babel transform and applies theme changes natively.
Does it work with Expo Go?
Yes. PanelUI is pure TypeScript with no native modules, so no development build or prebuild is required.
Is it accessible?
Every interactive component sets an accessibilityRole, mirrors its state through
accessibilityState, and exposes labels. Decorative icons are hidden from screen readers.
Can I use it in a bare React Native app?
Yes, as long as Uniwind, Reanimated and Gesture Handler are configured. Expo is the tested path.
Why do my Moon or Grass themes throw "it was not registered"?
Named themes must be listed in extraThemes in your Metro config — see Quick start.
Links
- Documentation: panelui.dev — a page per component, with anatomy, props and examples
- Source: github.com/panel-ui/PanelUI
- Report an issue: github.com/panel-ui/PanelUI/issues
- Changelog: releases
License
MIT © Khalid Abdi
