npm.io
0.1.4 • Published 19h ago

@limonify/zest-ui

Licence
MIT
Version
0.1.4
Deps
0
Vulns
0
Weekly
0

@limonify/zest-ui

npm license docs

Base UI for React Native — headless, unstyled, accessible primitive components.

@limonify/zest-ui is a React Native port of MUI Base UI. Same compound parts (Dialog.Popup, Dialog.Backdrop), same controlled/uncontrolled patterns — adapted for touch and React Native accessibility APIs.

Zero style. Zero theme. Zero animation library. You own the look; zest owns behaviour, state, and accessibility. Style with StyleSheet, Uniwind, NativeWind, Tamagui, or anything else.

Docs: zestui.limonify.com

Why zest

  • Base UI API — familiar compound components and part names, not a Radix rename
  • Headless — every part renders a plain RN primitive (View, Pressable, Text, TextInput)
  • State as stylestyle and className accept a function of state (the RN counterpart of data-*)
  • Accessible — roles and states map to accessibilityRole / accessibilityState (and keep aria-* for RN-web)

Install

bun add @limonify/zest-ui
# or: npm install @limonify/zest-ui

Peers: React 19+, React Native 0.79+.

Slider and Drawer need react-native-gesture-handler (optional peer). Install it only if you use them, and wrap your app once:

bun add react-native-gesture-handler
import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function App() {
  return <GestureHandlerRootView style={{ flex: 1 }}>{/* ... */}</GestureHandlerRootView>;
}

Anchored popups (Popover, Tooltip, Menu, Select, Combobox, …) use @floating-ui/react-native, which ships as a regular dependency — nothing extra to install.

Full install guide: zestui.limonify.com/docs/installation

Quick start

Style from state — no themes, no CSS variables:

import { StyleSheet } from 'react-native';
import { Switch } from '@limonify/zest-ui';

export function DarkModeToggle() {
  return (
    <Switch.Root style={(state) => [styles.track, state.checked && styles.trackOn]}>
      <Switch.Thumb style={(state) => [styles.thumb, state.checked && styles.thumbOn]} />
    </Switch.Root>
  );
}

const styles = StyleSheet.create({
  track: { width: 44, height: 26, borderRadius: 13, backgroundColor: '#d4d4d4', padding: 3 },
  trackOn: { backgroundColor: '#4c7a0b' },
  thumb: { width: 20, height: 20, borderRadius: 10, backgroundColor: '#fff' },
  thumbOn: { transform: [{ translateX: 18 }] },
});

Compound overlays use the same part tree as Base UI. Portals are React Native Modals:

import { Text, StyleSheet } from 'react-native';
import { Dialog } from '@limonify/zest-ui';

export function DeleteDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger style={styles.button}>
        <Text style={styles.buttonText}>Delete</Text>
      </Dialog.Trigger>

      <Dialog.Portal>
        <Dialog.Backdrop style={styles.backdrop} />
        <Dialog.Viewport style={styles.viewport}>
          <Dialog.Popup style={styles.card}>
            <Dialog.Title style={styles.title}>Delete file?</Dialog.Title>
            <Dialog.Description style={styles.body}>This can't be undone.</Dialog.Description>
            <Dialog.Close style={styles.button}>
              <Text style={styles.buttonText}>Cancel</Text>
            </Dialog.Close>
          </Dialog.Popup>
        </Dialog.Viewport>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

const styles = StyleSheet.create({
  button: {
    alignSelf: 'flex-start',
    backgroundColor: '#171717',
    borderRadius: 10,
    paddingVertical: 10,
    paddingHorizontal: 16,
  },
  buttonText: { color: '#fff', fontWeight: '600' },
  backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
  viewport: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 },
  card: { width: '100%', maxWidth: 340, gap: 8, borderRadius: 16, backgroundColor: '#fff', padding: 20 },
  title: { fontSize: 17, fontWeight: '700' },
  body: { color: '#737373' },
});

Common props

Every part accepts the same small set on top of its own API:

Prop Role
style RN style, or (state) => style
className String or (state) => string for Uniwind / NativeWind
render (props, state) => ReactNode — swap the host element, keep behaviour
ref Ref to the underlying element (React 19 — ref is a regular prop)
<Dialog.Trigger render={(props) => <MyButton {...props}>Open</MyButton>} />

<Checkbox.Root
  className={(state) =>
    ['size-6 rounded border', state.checked && 'bg-lime-600'].filter(Boolean).join(' ')
  }
/>

Toast

Most components need no provider. Toast is the exception — it must not block the app, so it is not a Modal. Mount Toast.Provider and Toast.Viewport once at the root:

import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Toast } from '@limonify/zest-ui';

export default function App() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Toast.Provider timeout={4000} limit={3}>
        <RootNavigator />
        <Toast.Viewport style={{ paddingHorizontal: 16, paddingBottom: 48 }}>
          {/* map toasts → Toast.Root — see https://zestui.limonify.com/docs/components/toast */}
        </Toast.Viewport>
      </Toast.Provider>
    </GestureHandlerRootView>
  );
}

Skip GestureHandlerRootView if you use neither Slider nor Drawer; skip Toast setup if you don't use toasts.

Components

32 components. Import from @limonify/zest-ui:

import { Dialog, Switch, Toast } from '@limonify/zest-ui';
Forms & input
Component Parts
Button
Input
Checkbox Root, Indicator
CheckboxGroup
Radio Root, Indicator
RadioGroup
Switch Root, Thumb
Toggle
ToggleGroup
Slider Root, Label, Value, Control, Track, Indicator, Thumb
NumberField Root, Group, Input, Increment, Decrement, ScrubArea
OTPField Root, Input, Separator
Field Root, Label, Control, Description, Error, Validity
Fieldset Root, Legend
Select Root, Label, Trigger, Value, Icon, Portal, Backdrop, Positioner, Arrow, Popup, List, Group, GroupLabel, Item, ItemText, ItemIndicator, Separator
Combobox Root, Input, Portal, Backdrop, Positioner, Popup, List, Item, Empty, Value
Autocomplete Root, Input, Portal, Backdrop, Positioner, Popup, List, Item, Empty
Disclosure
Component Parts
Collapsible Root, Trigger, Panel
Accordion Root, Item, Header, Trigger, Panel
Tabs Root, List, Tab, Indicator, Panel
Overlays
Component Parts
Dialog Root, Trigger, Portal, Backdrop, Viewport, Popup, Title, Description, Close
AlertDialog same shape as Dialog
Drawer Root, Trigger, Portal, Backdrop, Viewport, Popup, SwipeArea, Title, Description, Close
Popover Root, Trigger, Portal, Backdrop, Positioner, Popup, Arrow, Title, Description, Close
Tooltip Root, Trigger, Portal, Positioner, Popup, Arrow
Menu Root, Trigger, Portal, Backdrop, Positioner, Popup, Arrow, Item, LinkItem, CheckboxItem, RadioGroup, RadioItem, Group, GroupLabel, SubmenuRoot, SubmenuTrigger, Separator
ContextMenu Root, Trigger, Positioner (+ Menu item family)
Feedback & misc
Component Parts
Progress Root, Track, Indicator, Value, Label
Meter Root, Track, Indicator, Label, Value
Avatar Root, Image, Fallback
Separator
Toast Provider, Viewport, Root, Positioner, Arrow, Title, Description, Action, Close

Popup families also support handles (createHandle, detached triggers, imperative open) — see the docs for each component.

Design notes

Portals are Modals. Every Portal (Dialog, Drawer, Popover, Tooltip, Menu, Select, Combobox, …) is a React Native Modal. That keeps context across the portal boundary and wires Android back / Escape to dismiss. Toast is the exception: an absolutely positioned View with pointerEvents="box-none" so the app underneath stays usable.

State, not data attributes. On the web, Base UI publishes data-* and CSS variables. In zest, state reaches you through the state object on style / className / renderstate.open, state.checked, state.height, and so on.

zest never animates. Animated parts publish transitionStatus and measured geometry on state; you drive Animated (or Reanimated) yourself. Exit animations need keepMounted (or the part’s equivalent, e.g. Toast’s removeOnClose), otherwise the tree unmounts as soon as it closes.

Not ported

These Base UI pieces stay web/desktop-only:

Component Why
Form Aggregates HTML form submit validation — RN has no <form> submission; use Field’s validate
PreviewCard Hover card — no hover on touch
Menubar Desktop navigation pattern
NavigationMenu Desktop multi-level hover menu
ScrollArea RN already has ScrollView / FlatList
Toolbar Desktop toolbar / roving tabindex
CSPProvider Content Security Policy is web-only

Monorepo

This repository is a bun + turborepo workspace:

Path Role
packages/zest-ui The publishable library
apps/example Expo demo exercising every component
apps/docs Documentation site (zestui.limonify.com)
bun install
bun run test        # typecheck + tests via turbo
bun run build
bun run dev         # Expo example

The full implementation plan, milestones, and architecture notes live in ARCHITECTURE.md. Contributor notes for porting components live in packages/zest-ui/CLAUDE.md.

License

MIT Limonify