pixelize-native-library
v2.15.0
Published
Pixelize mobile design library — React Native / Expo. The mobile counterpart to pixelize-design-library.
Readme
pixelize-native-library
Pixelize's React Native / Expo design library (Pebble 2.0) — the mobile counterpart to
pixelize-design-library (web). Nine brands, light
and dark, corner and density controls, accessible in every state.
Building a screen? Start at DESIGN-GUIDE.md section 10: the screen-kind to reference-screen table.
Where to look (all of it ships in the package)
| Need | File in node_modules/pixelize-native-library/ |
| --- | --- |
| Every module, its exact import subpath, one line | registry.json |
| One module's props, behaviour and gotchas | lib/module/modules/<name>/MODULE.md (core: lib/module/core/<name>/MODULE.md); prop JSDoc shows in your editor via the .d.ts |
| Composing a screen that looks designed | DESIGN-GUIDE.md |
| Traps that compile and are wrong at runtime | COMPONENT-GOTCHAS.md |
| Keeping an app fast, smooth and small: the 10 rules, dates and locale/strings, the pinned native stack and its guard, the phone checks | CONSUMER-PERFORMANCE.md |
| Motion, reduced motion, low-end devices, and the app-side settings that make a screen fast (inlineRequires, stable handlers, loading props) | MOTION-AND-PERFORMANCE.md |
| Known gaps and bugs, with status | GAPS.md |
| Upgrading from 1.x | MIGRATION.md |
| Every release | CHANGELOG.md |
A hosted playground, https://mobilecomp.pixelizetech.com, shows every component live (/#/c/<id>, add
?brand=zinc&mode=dark&corner=round&density=default) and mirrors these docs as text: /llms.txt,
/components/<id>.md, /screens/<id>.md and /gaps.md. It tracks the newest deploy, so for an older pinned
version read the shipped files above first. With this repo checked out, npm run web runs the same playground plus 23
reference screens (example/screens/examples; the screen-kind table is DESIGN-GUIDE.md section 10).
Getting started
New to this library? Follow these in order. Coming from 1.x: read MIGRATION.md first.
Setup checklist
- Runtime. Expo SDK 57+ (the example runs Expo 57 on React Native 0.86) or bare React Native 0.82+. New
Architecture only (Reanimated 4 has no old-architecture build) and Hermes.
Expo Go carries Reanimated 4.5.1 and worklets 0.10.1; an app on any other pair (the Pixelize apps pin 4.7.1 and 0.13.0)
cannot open in it, because worklets compares its JS and native versions at start. Use a dev build, and see
CONSUMER-PERFORMANCE.mdfor the pins and the guard. - Peers. Install the required ones and the optional ones you use, below, then rebuild the native app.
- Babel. Reanimated 4 moved its plugin to
react-native-worklets/plugin, and it must be the last entry inplugins.babel-preset-expoadds it itself whenreact-native-workletsis installed, so an Expo app needs no entry (react-native-reanimated/pluginis only an alias of it in 4.x). Bare React Native: add it, last. - Metro. Subpaths such as
pixelize-native-library/data-tableresolve through packageexports. Metro enables that by default in the versions this library targets (Expo 57, React Native 0.86), so the setting is redundant there; on an older bare setup setconfig.resolver.unstable_enablePackageExports = true. - TypeScript.
"moduleResolution": "bundler"(Expo'sexpo/tsconfig.basesets it).node16andnodenextalso work; classicnodecannot see subpath types. - Root wrappers, in this order:
GestureHandlerRootView(style={{ flex: 1 }}) >SafeAreaProvider>PixelizeProvider> your navigator. Swipe, drag, slider and week-strip gestures need the first; insets are guesses without the second. - Fonts. Load Plus Jakarta Sans (or Inter) before the first render; see Typography.
- Injectors. Copy
pebble.setup.ts(below) and import it first in your entry file. - Jest. Add the lucide mapper from "Testing your app with jest".
- Imports. Heavy modules and the core surfaces are subpath-only; see "Finding a component".
Install
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler react-native-svg \
react-native-safe-area-context expo-font @expo-google-fonts/plus-jakarta-sans
npm i pixelize-native-library lucide-react-native@^1.47.0Add the optional packages you use with npx expo install <name> (npm i for the two non-Expo ones).
| Package | Floor | Status | Used for | Native rebuild |
| --- | --- | --- | --- | --- |
| react, react-native | 19.0, 0.82 | required | everything | n/a |
| react-native-reanimated, react-native-worklets | 4.5 to 4.7 (<4.8), 0.10 to 0.13 (<0.14) | required | all motion | yes |
| react-native-gesture-handler | 2.32 (^2.32.0; 3.x not checked) | required | swipe, drag, sliders, sheets | yes |
| lucide-react-native | 1.47 | required | every icon | no |
| react-native-svg | 15.15 (<16) | required | icons, charts, rings | yes |
| react-native-safe-area-context | 5.0 | optional, install it | real insets, overlay insets on Android | yes |
| @react-native-async-storage/async-storage | 2.0 | optional | PixelizeProvider storage (persist theme) | yes |
| @shopify/flash-list | 2.0 | optional | VirtualList and ListScreen use it when present | no |
| expo-blur | 57 | optional | glass surfaces (AppBar, BottomNav, SaveBar) | yes |
| expo-haptics | 57 | optional | haptic feedback | yes |
| expo-device | 57 | optional | Android phones with 4 GB of RAM or less get the low-end motion path | yes |
| expo-image | 57 | optional | MediaImage, Avatar images | yes |
| expo-image-picker, expo-document-picker | 57 | optional | FileUpload, AvatarUpload | yes |
| expo-video, expo-audio | 57 | optional | MediaViewer playback | yes |
| expo-sharing, expo-file-system | 57 | optional | data-export CSV / .xlsx share sheet | yes |
| react-native-view-shot | 4.0 | optional | theme crossfade | yes |
| @react-native-clipboard/clipboard or expo-clipboard | 1.14 / 57 | optional | CopyButton on native; expo-clipboard through setClipboardModule | yes |
| react-native-webview | 13.0 | optional | PdfDocumentRenderer, the PDF pages in DocumentViewer and MediaViewer | yes |
| react-native-enriched-html | 1.0 | optional | rich-text-editor (a dev build, not Expo Go) | yes |
| react-dom | 19.0 | optional | React Native Web only | no |
Why lucide must be 1.47 or newer: every icon is a deep import (lucide-react-native/icons/<glyph>) that
Metro resolves at bundle time, even for icons you never render. Three glyphs do not exist before 1.47, so an
older copy fails the whole bundle with Unable to resolve module lucide-react-native/icons/... on any screen
that imports Button, Select or Icon. Check npm ls lucide-react-native: a lockfile can pin an old one.
Typography
The library ships no font — without one it falls back to the system font and the provider logs a dev warning. Plus Jakarta Sans is the Pebble face; Inter is supported too.
import { useFonts } from "expo-font";
import {
PlusJakartaSans_400Regular,
PlusJakartaSans_500Medium,
PlusJakartaSans_600SemiBold,
PlusJakartaSans_700Bold,
PlusJakartaSans_800ExtraBold,
} from "@expo-google-fonts/plus-jakarta-sans";
const [fontsLoaded] = useFonts({
"PlusJakartaSans-Regular": PlusJakartaSans_400Regular,
"PlusJakartaSans-Medium": PlusJakartaSans_500Medium,
"PlusJakartaSans-SemiBold": PlusJakartaSans_600SemiBold,
"PlusJakartaSans-Bold": PlusJakartaSans_700Bold,
"PlusJakartaSans-ExtraBold": PlusJakartaSans_800ExtraBold,
});Pass fonts="jakarta" to PixelizeProvider once fontsLoaded is true (below). fonts="inter" expects
the faces Inter-Regular, Inter-Medium, Inter-SemiBold, Inter-Bold and Inter-ExtraBold instead;
fonts also takes a { 400: "MyFace-Regular", … } map, and a weight you leave out falls back to the
nearest registered face, then the system font. interLoaded is a deprecated alias of fonts="inter".
The provider
Wrap the app root once, after fonts finish loading, inside the two root wrappers from the checklist:
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { PixelizeProvider, createAsyncStorageAdapter } from "pixelize-native-library/theme";
import { Button } from "pixelize-native-library";
export default function App() {
// const [fontsLoaded] = useFonts({ ... }); if (!fontsLoaded) return null;
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<PixelizeProvider
brand="zinc"
mode="system"
fonts="jakarta"
density="compact"
storage={createAsyncStorageAdapter(AsyncStorage)}
>
<Button label="Save" onPress={save} />
</PixelizeProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}Start every new app at density="compact". A phone is small: compact uses its full width and height
(12 dp gutter, 12 dp between blocks, about one more row per screen) and shrinks no text and no 44 dp touch
target. The library's own default is default, so you must ask for it. Build screens compact from day one: read
every gutter and gap from usePebble().layout and never write a 16 or 24 for padding, margin or gap
(DESIGN-GUIDE.md section 2a, with a block to paste into your app's CLAUDE.md).
PixelizeProvider props are all optional: brand (zinc default), mode (light | dark | system,
default system), corner (sharp | soft | round, default round), density (compact |
cozy | default | comfortable, default default; use compact in new apps, cozy as the middle step), fontScale (0.8 to 1.5, default 1), fonts, accent, palette, storage, haptics, statusBar (default on), themeTransition, locale (the default for every date picker's month and weekday names), strings (your own words for the date pickers' Cancel, Done, placeholders, presets and spoken labels; keys you leave out stay English). Details:
lib/module/theme/MODULE.md.
storage persists mode, brand, corner, density and text scale across restarts; it needs
@react-native-async-storage/async-storage installed, and createAsyncStorageAdapter wraps it so the
library itself never imports that package directly. Without storage the provider still works, but keeps
the choice in memory only (lost on restart) and logs a dev warning saying so.
Startup file: pebble.setup.ts
Optional native modules are handed to the library once, so nothing imports them at module scope. Import this
file first in your entry file (import "./pebble.setup" above registerRootComponent) and delete the lines for
packages you did not install: Metro fails the bundle on a require of a package that is missing.
import { Platform } from "react-native";
import { Image } from "expo-image";
import * as DocumentPicker from "expo-document-picker";
import * as ImagePicker from "expo-image-picker";
import {
setAudioModule,
setDocumentPicker,
setExpoImage,
setImagePicker,
setVideoModule,
} from "pixelize-native-library/media";
import { setHapticsModule } from "pixelize-native-library/haptics";
import { setBlurModule } from "pixelize-native-library/blur";
import { setViewShotModule } from "pixelize-native-library/theme";
import { setFileSystemModule, setSharingModule } from "pixelize-native-library/data-export";
import { setClipboardModule } from "pixelize-native-library/copy-button";
import { PdfDocumentRenderer, setDocumentRenderer } from "pixelize-native-library/document-viewer";
setExpoImage(Image as never);
setImagePicker(ImagePicker);
setDocumentPicker(DocumentPicker);
setVideoModule(require("expo-video"));
setAudioModule(require("expo-audio"));
setSharingModule(require("expo-sharing"));
setFileSystemModule(require("expo-file-system"));
setClipboardModule(require("expo-clipboard"));
setHapticsModule(require("expo-haptics"));
setBlurModule(require("expo-blur").BlurView);
if (Platform.OS !== "web") setViewShotModule(require("react-native-view-shot"));
setDocumentRenderer(PdfDocumentRenderer);| Setter | Import from | Without it |
| --- | --- | --- |
| setImagePicker, setDocumentPicker | /media (also /file-upload and the root) | FileUpload, AvatarUpload throw on pick |
| setExpoImage | /media | MediaImage falls back to React Native Image |
| setVideoModule, setAudioModule | /media | MediaViewer shows an "is not set up" panel for video and audio |
| setSharingModule, setFileSystemModule | /data-export (also the root) | shareTabularData, shareTextFile return unavailable; they never throw |
| setClipboardModule | /copy-button (also the root) | on native, CopyButton reports failure unless @react-native-clipboard/clipboard is installed |
| setHapticsModule | /haptics | no haptics |
| setBlurModule | /blur | glass surfaces render an opaque fill instead of a blur |
| setViewShotModule | /theme | theme change is instant, no crossfade |
| setDocumentRenderer | /document-viewer (also the root) | DocumentViewer offers "Open in browser"; MediaViewer PDFs get an Open, Share and Download panel. PdfDocumentRenderer needs react-native-webview and reaches cdnjs for pdf.js |
Bring your own brand colour
A consumer app that wants its own brand colour does not need to add a new brand folder under this
library's src/theme/tokens/brands/. Pass a single hex to accent:
<PixelizeProvider brand="zinc" accent="#3a75cf" mode="system">
{/* your app */}
</PixelizeProvider>accent is turned into a full, contrast-checked PaletteProps by paletteFromAccent(accentHex, brand,
resolvedMode) (pixelize-native-library/tokens). It takes the brand you passed as a structural
template — that brand's neutral/gray scale, status colours, semantic text and surfaces are reused
unchanged — and rebuilds only the primary ramp (and the two-stop gradient derived from it) from your
hex. So brand still matters with accent set: it picks the neutral/status base the rest of the UI is
built from, and only the accent ramp changes. Any hex is safe: the resulting fill is checked against
that base (3:1 as a mark on bg, surface and raised; label ink 4.5:1 at rest and pressed), so it clears the
same gates every shipped brand does, exercised for 73 accents x 9 brands x both modes in
accentStress.test.ts. A hex that already passes is used byte-for-byte (#3a75cf never moves); one that
cannot (near-black in dark, near-white in light, mid-greys, some pure blues and magentas in dark) is moved in
lightness only, same hue, so the same hex can render slightly lighter in dark than in light. Accepted input is
#RGB or #RRGGBB (uppercase and a missing # are fine); anything else throws a clear error at mount.
Because light and dark need different structural bases, the provider
re-derives the palette itself on every mode="system" flip — you don't have to.
For more control than one hex gives (a hand-tuned ramp, a different gradient, a bespoke status colour),
pass a fully-authored palette prop instead — a PaletteProps object, which wins over both brand and
accent. A common starting point is paletteFromAccent's own output, then override individual fields.
Unlike accent, palette carries no mode awareness of its own — it is a static object, so rebuild it
yourself whenever resolvedMode changes (read it from useThemeMode(), see below):
import { paletteFromAccent } from "pixelize-native-library/tokens";
<PixelizeProvider palette={paletteFromAccent("#3a75cf", "zinc", resolvedMode)}>
{/* your app */}
</PixelizeProvider>;Most apps only need accent; reach for palette only when accent alone can't express what you need.
Finding a component
Most components come from the root barrel:
import { Button, Card, Screen, Text } from "pixelize-native-library";Not on the root barrel (a root import fails to resolve; use pixelize-native-library/<name>):
- the 19 heavy modules
bottom-nav,charts,coachmark,context-menu,data-table,date-picker,filter-sheet,gantt,image-viewer,kanban-board,media-viewer,module-bar,onboarding-pager,phone-input,progress-card,reorder,rich-text,rich-text-editorandwidget-board(theHEAVY_MODULESset inscripts/gen-index.mjs); - the deprecated
state-blockalias (LoadingStateandErrorStateare on the root); - the core surfaces
icon(Icon),tile(IconTile),blur(GlassSurface),media(MediaImage),haptics(useHaptics),theme,tokensandstyle. The root carries only the injector setters ofhaptics,blurandmedia.motionis on the root too.
The root barrel pulls every light module: importing a Button from it bundled about +1.5 MB minified JS against about
+448 KB from pixelize-native-library/button (measured, GAP-095). Import by subpath in an app you ship; keep
the root for prototypes. This is not tree-shaking; Metro cannot do that for a library.
A Fab or FabGroup above the floating BottomNav takes offset={useBottomNavFabOffset()} (from
pixelize-native-library/bottom-nav); never derive it from useBottomNavInset(), which already contains the
safe inset the Fab adds itself.
Before building something by hand, find it here, then read its MODULE.md. Every name imports as
pixelize-native-library/<module>; a dagger marks a subpath-only module. registry.json has the full list.
Which component for which job
| Job | Modules (main exports) |
| --- | --- |
| Text fields | input (Input, PasswordInput), textarea, number-input (NumberInput, CurrencyInput), pin-input, otp-code-step, search-bar, rich-text-editor†, wrapped in form-field |
| Pickers (draw their own label; never in form-field) | select, multi-select, combobox, tag-input, phone-input†, date-picker† (DatePicker, DateRangePicker, TimePicker, Calendar, WeekStrip), hrms-month-calendar |
| Choice and input controls | checkbox, radio-group, switch, segmented-control, chip, choice-card, slider, rating, quantity-stepper, file-upload, avatar-upload, save-bar |
| Actions | button (Button, IconButton, ToggleButton), button-group, fab, link, social-button, copy-button, upgrade-button |
| Lists and rows | list-item, list-screen, section-list (SectionList, IndexBar), card-group, setting-row, settings-section, key-value, description-list, payslip-line-list, swipeable-row, reorder†, data-table† (DataTable, CardTable), accordion, timeline, pagination, more-items, file-grid, folder-row |
| Navigation and screens | screen, auth-layout, app-bar, bottom-nav†, module-bar†, tabs, breadcrumbs, stepper, stage-progress, pager-dots, carousel, onboarding-pager†, motion (StackTransition, FadeSwitch), drawer, app-switcher, org-switcher, project-switcher, project-tile, filter-bar, filter-sheet† (FilterSheet, SortSheet, FilterChip), field-select-modal |
| Feedback and state | toast, banner, empty-state, error-state, loading-state, result-state, skeleton, spinner, progress-bar, progress-ring, progress-card†, pull-to-refresh, tooltip, coachmark†, notification-bell, count-badge, unread-badge, status-dot, launch-hold, refresh-pill |
| Overlays | modal, alert-dialog, bottom-sheet, action-sheet, menu, context-menu†, popover, drawer, backdrop, limit-reached-modal |
| Data display | card, surface, text (Text, Heading), section, divider, amount, stat-tile, stat-grid, stat-chip-row, widget-board†, badge, tag, avatar, avatar-group, person-chip, attachment-tile, quick-actions, plan-badge, plan-pill, appearance-settings |
| Domain cards | product-card, property-card, ticket-card, event-card, profile-card, review-card, balance-card, plan-card, payment-card, promo-card, notification-card, action-card, proposal-card, approval-queue-card, kanban-card, signed-in-card |
| Charts and timelines | charts† (BarChart, LineChart, AreaChart, ScatterChart, PieChart, DonutChart, Sparkline, BarList, Gauge, FunnelChart), gantt†, kanban-board† |
| Media and documents | media-viewer† (photo, video, audio, PDF, text, files), image-viewer†, document-viewer, markdown, rich-text†, voice-note, media (MediaImage), icon (Icon), tile (IconTile) |
| Chat | message-bubble, composer, comment-thread, reaction-bar, date-separator, typing-indicator, voice-note |
| Theme and utilities | theme, tokens, style (sx, layout types), haptics, blur, data-export (shareTabularData, shareTextFile), date-format (formatDisplayDate, formatDisplayDateTime, formatDisplayDateRange) |
Each prop carries a one-line JSDoc, so hover or go-to-definition on the import answers most "what does this
prop do" questions. The longer version is each module's MODULE.md, under lib/module/modules/<name>/.
Dark mode and mode switching
mode on the provider is only the initial value. Read and change it at runtime with useThemeMode(),
called from anywhere inside the provider:
import { useThemeMode } from "pixelize-native-library/theme";
import { Button } from "pixelize-native-library";
function AppearanceToggle() {
const { resolvedMode, setMode } = useThemeMode();
return (
<Button
label={resolvedMode === "dark" ? "Switch to light" : "Switch to dark"}
onPress={() => setMode(resolvedMode === "dark" ? "light" : "dark")}
/>
);
}useThemeMode() also returns mode (the raw, unresolved setting), brand and setBrand, plus corner/setCorner,
density/setDensity and fontScale/setFontScale (an in-app text size, 0.8 to 1.5, on top of the OS one). resolvedMode
is mode with 'system' already resolved against the OS setting, and updates live if the OS setting
changes under a mode="system" app. The setters write through the storage adapter passed to
the provider, so the choice survives a restart — with no adapter it survives only until the app is
closed. There is no shipped ThemeSwitcher component or a toggle() helper; build the control your app
needs from mode / resolvedMode / setMode as above.
Testing your app with jest
Icon deep-imports lucide's ESM .mjs glyphs, which jest does not transform, so any screen test that renders an
Icon fails with Cannot use import statement outside a module. Map the deep imports to lucide's CommonJS build
and let jest transform the library itself (jest.config.js):
module.exports = {
preset: "jest-expo",
transformIgnorePatterns: [
"node_modules/(?!((jest-)?react-native|@react-native|expo(nent)?|@expo|pixelize-native-library|lucide-react-native)/)",
],
moduleNameMapper: {
"^lucide-react-native/icons/(.*)$": "<rootDir>/node_modules/lucide-react-native/dist/cjs/icons/$1.js",
},
};Glyphs then render as real react-native-svg nodes. To skip drawing them, mock the icon subpath in your setup file
instead (Icon is the only export that needs replacing):
jest.mock("pixelize-native-library/icon", () => ({
...jest.requireActual("pixelize-native-library/icon"),
Icon: () => null,
}));Reporting a gap or bug
- Check first. Read
GAPS.md, the component'sMODULE.mdandCOMPONENT-GOTCHAS.md. Many "bugs" are listed there, some alreadyfixed in <version>. - Reproduce on the playground page for that component (
npm run webin this repo, or the hosted/#/c/<id>). It reproduces there: a library bug. It does not: it is your usage or theme setup. - File it with
gh issue create --repo pixelize-tech/Mobile-Components --title "[gap] <component>: <one line>" --body-file report.md, the body laid out as inGAPS.md("Report format"; the human web form is theconsumer-gapissue template). Noghaccess: print the filled body for a human to file. Always include the library version, brand, mode, corner, density and device. - Track it in your own repo's notes with the issue link, and on every upgrade re-read
GAPS.mdand the changelog forfixed in <version>before you rely on a workaround. - Maintainers flip the row in
GAPS.mdtofixed in <version>in the same PR that fixes it.
Why subpath imports
pixelize-native-library/button pulls Button's module subtree and nothing else; the root barrel pulls all
of the light modules (about 3x the bytes, see "Finding a component"). Heavy modules are not on the root at all.
This is not tree-shaking. Metro's tree-shaking is experimental, production-only and enabled by a flag
the consumer app sets; a library cannot turn it on. Package Exports work in dev and prod, on bare RN and
Expo, with no config in your app. Pair it with Metro's inlineRequires so the modules you do import
evaluate on first use rather than at startup; the other app-side speed settings are in
MOTION-AND-PERFORMANCE.md and CONSUMER-PERFORMANCE.md.
Docs
The consumer docs are the files in "Where to look" above; all ship in the package. Repo only, not shipped:
- Contributing and the API contract:
CLAUDE.md - The performance checklist, gates and phone checks:
PERFORMANCE-PLAYBOOK.md; native versions and the guard:docs/NATIVE-STACK.md - Playground and reference screens:
example/README.md
Theme
Colour is resolved from one Pebble palette per brand and mode; useThemeColors() keeps the 1.4 shape and
is derived from it, while new code reads usePebbleColors(). The token model is in
lib/module/theme/tokens/THEME-CONTRACT.md. Font registration, the accent/palette props and mode switching are covered in Getting started above.
Web gotcha
Reanimated.Text typechecks but is undefined at runtime on web. Module code uses AnimatedText from
core/motion, never Reanimated.Text.
