Back to home
|
Published (0.4.0) — adopted by both platforms

Design System

A universal design system — one component API that runs on React Native and React web — extracted from a real app and published as a private package.

React NativeReactTypeScriptExpoTailwind CSS v4Reanimatedlucide-reactGitHub PackagesMonorepoa11y

This project grew out of My Finances. Its mobile app had around 35 hand-built components in pure React Native StyleSheet, and I kept wanting to reuse them — in the web companion and in future projects — without copy-pasting code. So I extracted them into their own package, @logar9911/ui, and made it universal: every component ships a native implementation and a web one behind the same props, so a consumer just imports it and its bundler picks the right one. It is the component inventory my own development tooling builds against.

Why extract a design system

The components already existed and worked, but they lived inside one app, coupled to its theme store, its navigation and its i18n. Reusing them meant copying files and re-wiring imports every time — and the web companion could not use them at all, since they were React-Native-only. The goal was to pay that cost once: lift the components into a standalone, product-agnostic package that both a React Native app and a React web app could consume with the same import, and that could grow independently of any single project.

How it is built

The package is universal by construction: shared prop contracts, one implementation per platform behind them, and a theme that is injected rather than baked in.

Platform-split components

Each component lives in its own folder with a native file (Component.tsx, the default Metro resolves) and a web file (Component.web.tsx, which Next/webpack resolves through a .web-first extension order). Both share the same props, so nothing at the call site changes across platforms. Where it pays off, a component adds a third file — Component.ios.tsx — for the iOS 26 Liquid Glass look, which Metro picks up on iOS only and which checks the glass API once at load time and renders the plain treatment when it is unavailable. The public barrel exports the native graph; a separate web barrel, wired through the package browser field, exports only what has a web implementation, so React-Native-only code never reaches the web bundle.

Injected theme (ThemeProvider + CSS vars + Tailwind v4)

The package knows nothing about any product colors: it defines the shape — 18 color tokens — and receives them through ThemeProvider. On native it is plain React context; on web the provider also writes the active colors as --mf-* CSS variables on a wrapper, and a shipped styles.css maps them to Tailwind v4 tokens. Light/dark stays runtime: switching schemes just rewrites the variables, with no rebuild.

Strangler-fig extraction

Components moved out of the app one batch at a time, each leaving a one-line re-export shim at its old path, so the app hundreds of import sites never had to change while the migration was in flight. Connected components (store, navigation, i18n) were split: the presentational half moved to the package, and a thin wrapper injecting the product behavior stayed in the app.

Source-only publishing

The package publishes its TypeScript source, not a compiled bundle — the consumer own toolchain transpiles it, exactly as a workspace package would. That sidestepped the hardest part of shipping dual-platform types (a single build target cannot emit both React-Native and DOM types cleanly) and keeps the published artifact identical to what is tested.

Bridging native and web

Where native and web have no common primitive, the web implementation rebuilds the behavior with web-native tools instead of dragging React Native into the browser.

Icons — @expo/vector-icons to lucide-react

Native components draw MaterialIcons; the web versions map each icon name to lucide-react through a lookup table that tolerates library renames (candidate arrays per icon) and degrades to a fallback glyph rather than crashing on a missing name.

Animation — Reanimated to CSS / Web Animations API

The animated components use Reanimated on native. On web the same motion is rebuilt with CSS transitions and keyframes, the Web Animations API for imperative effects (a PIN shake, a loading bar), and CSS-grid tricks for expand/collapse — with no animation library in the web bundle.

Navigation and gestures

BackButton drops expo-router on web for history.back() (or an injected handler); SwipeableItem swaps gesture-handler for pointer events and CSS transforms; RefreshIcon reimplements its react-native-svg drawing as inline DOM SVG.

Charts — victory-native/Skia to Recharts

BarChart draws with victory-native and Skia on native and with Recharts on web, behind one props API like everything else. Since those engines are heavy and have nothing in common, it ships through a secondary entry point — @logar9911/ui/chart — instead of the main barrel, so a consumer that draws no charts never pulls them into its tests or its bundle and the charting peers stay genuinely optional.

Accessibility

Both platforms got an accessibility pass against WCAG 2.1 AA. Native components carry accessibilityRole, State and Label (switches, checkboxes, radios, progress bars, the toast as a live region); web components use real ARIA — interactive icons are actual buttons with labels, not click handlers on a span, and controls expose role and checked/expanded state. The brand primary was darkened from #0996c0 (3.4:1 on white, failing AA) to #07789a (5.05:1, passing) across the theme.

Distribution and structure

Private registry (GitHub Packages)

Published to GitHub Packages under the @logar9911 scope (the scope must match the owner account). A git tag triggers a CI workflow that runs npm publish; consumers authenticate with a GitHub token scoped to read:packages.

Optional peers per platform

Every platform dependency (Reanimated, gesture-handler, expo-router, react-dom, lucide-react and the rest) is a peer marked optional via peerDependenciesMeta, so a web consumer never fails for missing Reanimated and a native one never fails for missing react-dom.

One folder per component

Each component is a self-contained folder — the index (the platform-resolved export), the native and web implementations — plus an .ios one where Liquid Glass applies — and its test, so a component is a single unit to read, test, move or delete.

Consumed as a published dependency

The reuse goal, fully realized: the origin app installs the package from the registry — source-only, transpiled by the consumer's own bundler (Next transpilePackages on web, Metro on native) — and both of its platforms were migrated onto the catalog. Mobile dropped its re-export shims; web deleted its five duplicate primitives and composes every screen from the package, keeping only thin wrappers that inject product behavior.

Stack and practices

Core

TypeScriptReact 19React Native 0.83Expo SDK 55Next.jsReact DOM

Web

Tailwind CSS v4CSS Variableslucide-reactWeb Animations APIRecharts

Native

Reanimatedreact-native-gesture-handlerreact-native-svgreact-native-safe-area-context@expo/vector-iconsexpo-routerexpo-glass-effectvictory-native@shopify/react-native-skia

Tooling and delivery

pnpm workspacesTurborepoJestjest-expoTesting LibraryGitHub PackagesGitHub Actions

Interactive demo

Living documentation of the catalog: faithful replicas of the @logar9911/ui web implementations, with their real geometry, states and variants. Every example is interactive, and each snippet shows the API exactly as a consumer writes it — the same call works on React Native and on the web.

Scheme
Primary

This is the injected theme in action: the package ships no colors of its own — it receives 18 tokens through ThemeProvider. Switch the scheme or the primary and every component repaints at runtime, with no rebuild. That is how one catalog serves products with different identities.

The catalog's action triggers. Button: pill-shaped, four variants (primary, outline, destructive, ghost), three sizes, plus loading and disabled states — on the web it accepts submit for forms. BadgeIconButton: a circular icon button with a count badge, the presentational half of the notifications button.

Live example

Button

BadgeIconButton

Click it: the badge count goes up

Use case

The destructive and ghost variants were born from migrating the My Finances web app: its screens needed them and the catalog adopted them for both platforms instead of allowing a local fork. Try the primary one (it triggers its loading state) and the bell (it bumps its counter).

Usage (same API on native and web)

import { Button, BadgeIconButton } from "@logar9911/ui";

<Button title="Guardar" onPress={save} />
<Button title="Cancelar" variant="outline" block onPress={close} />
<Button title="Eliminar" variant="destructive" size="sm" onPress={del} />
<Button title="Enviando…" loading onPress={submit} />

<BadgeIconButton iconName="notifications" count={unread} onPress={open} />

The replicas on this page reproduce the package's web implementations with their real geometry and states; the snippets show the published API. 19 of the 31 components are shown here — the full package (charts included) runs in production in the My Finances mobile and web apps.

Current status

Version 0.4.0 is published and adopted by both platforms of the origin app, with zero duplicated components left. The release was validated end to end against that real consumer installing from the registry — typecheck, unit tests, the Next production build and the Metro/Hermes bundle all pass, which settled the open question of whether source-only publishing works across both bundlers. The migration itself drove the API forward: gaps the web screens exposed became catalog extensions rather than local forks — Button gained variants (destructive, ghost), sizes and form-submit support on both platforms; Modal gained a desktop dialog presentation on web while native keeps its bottom sheet; Input gained web-native number and date types. Even the type system learned from the consumer: @types/react became an optional peer after two copies of it in the consumer's tree broke the typecheck of the published sources.

Want to talk about the project?

I am happy to walk through the platform-split design or the extraction process.

Contact me