Skip to contentAsheeUIAsheeUI

React Native

The React Native renderer: the same contracts, and exactly where the platform differs.

AsheeUI ships for React Native as well as the web. The native package is @asheeui/native, styled with NativeWind, and it shares the framework's component vocabulary, prop contracts and design language with the web implementation without sharing its source.

The rule behind the whole platform story is simple: the contract is shared, the implementation is the platform's. A native button is a native button, not a web button with the browser removed.

What is shared, and what is not

Layer Lives in Shared?
Component names and prop contracts @asheeui/core Yes
Design language (spacing, radius, typography, colour roles, elevation, breakpoints, touch target) @asheeui/core Yes
The four-tier configuration cascade @asheeui/core Yes
Compatibility matrix (what each component means on each platform) @asheeui/core Yes
Component implementation @asheeui/web (web), @asheeui/native No, by design
Styling technology Tailwind classes (web), NativeWind classes (native) No

Sharing the source would mean one of the two platforms gets the other's behaviour. Sharing the contract means a developer learns one component list and one set of prop names, and each platform behaves the way its users expect.

Status

The native package has a component set, and it is now the whole matrix: every one of the 58 components the compatibility matrix promises for native is implemented and tested with the platform's own tooling, and scripts/native-parity.json holds no queued module.

Area Delivered
Layout Container, Stack, HStack, VStack, Grid, Section, Centered, Split, Page with PageHeader, PageContent and PageFooter
Components Button, Text, Card, Badge, Input, Chip, Avatar, Image, Clipboard, Marquee, Spinner, Skeleton
The field family Textarea, SearchInput, PinInput, Switch, Radio and RadioGroup, Dropdown, MultiSelect, Autocomplete, Form, Stepper, FileUpload, Calendar
Feedback and state Alert, EmptyState, ErrorState, LoadingState, and the ToastProvider with useToast
Navigation and disclosure Link, Tabs, Accordion, Carousel, Pagination
The page shell Navbar with its TabBar, Sidebar, SidebarLayout, DocsLayout, AuthLayout
A marketing page MarketingLayout, Hero, FeatureGrid, CTA, Footer, Testimonials, PricingCard
Data display Table and DataTable, both shipped as RowList
Overlays Modal, Drawer, Tooltip
Responsive useBreakpoint, and the pure resolveBreakpoint and resolveGridColumns
Configuration Every component registers its defaults and resolves through the shared cascade
A composed page @asheeui/puck renders a page the web editor composed, with the components above

The field family arrived as one increment rather than one component at a time, because its members are the same shape: they read the family's vocabulary from @asheeui/core (FieldConfig, the field class dictionaries and the two rules the family states once), they draw the same label block through FieldShell, and they resolve the same cascade. What each one adds is its own control and its own configuration section.

Two of the family's members read the platform rather than a preference, and both are recorded in the matrix as shared API rather than hidden:

  • A code field types into one field behind its boxes, because a native control has a single caret; the boxes draw what the field holds. The rules are unchanged: PinInput reads sanitizePinValue from @asheeui/core, so a web field and a native field filter the same input the same way.
  • An autocomplete shows its suggestions under the field rather than floating them beside it, because a reader comparing what they typed with what it matches has to see both at once.

Calendar closes the family. It collects a date, a time or both, and what the platform forces is drawn and recorded rather than quietly ignored: the field opens its calendar on one surface at a time, because the platform has no floating layer for a panel beside a control, and the time is stepped through columns rather than picked from a platform clock, because a platform time picker takes a colour value while the contract names a colour role, and a control that cannot resolve the role would accept the prop and ignore it. What is not the platform's is the framework's, and it is read from @asheeui/core: the month grid, the written form of a chosen date, the two rules that decide which cells a reader may reach (isFutureDay and canGoToNextMonth), and the vocabulary that says what an empty field collects. A day the web refuses is a day native refuses.

The feedback and state family arrived next, as one increment, for the same reason the field family did: an Alert, an EmptyState, an ErrorState and a LoadingState are four presentations of one question, what is this region telling the reader, and the ToastProvider is the same question raised above the screen. Three things are shared rather than restated:

  • How a message is announced. The framework names two levels, and the platform has no status role, so @asheeui/core states the relation between the two vocabularies once (NATIVE_ANNOUNCEMENT_LIVE_REGION and resolveNativeAnnouncementRole), and an alert, a state and a toast all announce themselves through it. A failure interrupts on the platform because it interrupts on the web.
  • What a message looks like. The treatments and colour roles a message surface can wear are one pair of maps (NATIVE_MESSAGE_SURFACE_CLASS, NATIVE_MESSAGE_TEXT_CLASS), read by the alert and by the toast, so retuning one retunes the other.
  • How long a queue waits. The toast's default timeout and its queue depth are stated in @asheeui/core (TOAST_FALLBACK_TIMEOUT_MS, TOAST_FALLBACK_MAX_TOASTS), because they are decisions about a reader's attention rather than about a platform.

What the platform forces is drawn and recorded rather than quietly ignored. A decoration is a character rather than a drawing, because this package ships no icon set and its other components already draw their affordances from text. The toast stacks its messages in a layer of its own and anchors them to the bottom centre by default, because the platform's own snackbar cannot carry the title, the action or the dismissal the contract names, the same argument that keeps the native spinner a themed ring rather than an activity indicator. An EmptyState has no as prop, because the platform has one container. And the technical detail of an ErrorState is opened from the component's own state, because the platform has no details element and the detail is not application state.

The rest is the framework's, read from the same place the web reads it: resolveCascade for every option, NATIVE_RADIUS_CLASS for corners, SPACE_MIN_HEIGHT_CLASS for the room a loading region claims, and one helper (openDestination) for the destination a configured action describes.

The navigation and disclosure family followed, and it is the family where the platform's own behaviour does most of the work. What is shared is the vocabulary and the scale: a link's emphasis, underline and density, a tab's items and its active and inactive treatment, an accordion's items and its treatments, a carousel's slides and its timing. What the platform does with them is read from the platform rather than imitated:

  • A link underlines itself while it is pressed, because there is no pointer to hover with, and it follows its destination through the platform's URL handler unless the consumer handles the press itself or names the component that should render the link, the same substitution API the web link offers.
  • A tab bar scrolls with the platform's scroll view rather than with a wrapped overflow, and the bar is not an accessibility element of its own: a container that is one hides the controls inside it from a reader, so a native tab bar is a row of tabs, each carrying its role and whether it is selected.
  • An accordion opens with the platform's own layout animation rather than with a measured height, and a closed panel is not drawn at all, which is what takes it out of the accessibility tree, the platform's answer to the web's inert.
  • A carousel pages with the platform's scroll view, so a slide arrives where the screen is. That is also why loop wraps only the autoplay here: a scroll view that teleported its content back to the start would be lying about where the reader is. pauseOnHover becomes "pause while a thumb is on it", which is the same intent on a platform with no pointer.

Two things the family states that the web states differently on purpose: a treatment a component has none for, a global default stated for a filled control, resolves to the treatment the component documents, and a decoration is a character rather than a drawing, because this package ships no icon set.

The overlays came last, and they are the family where the platform's own arrangements replace the framework's. What is shared is what an overlay is: a modal's widths and positions, a drawer's sizes and placements, a hint's treatments, colour roles, sizes, placements, arrow, offset and delay, all of it read from @asheeui/core, so a consumer configures them once for both.

What is not shared is the panel itself, and the platform's answer is better than a copy of the web's would be:

  • A modal is the platform's own modal presentation, which takes the screen above everything else, keeps the reader inside it, and hands the framework the platform's way out, the back gesture or button, through the platform's own callback. That is what closeOnEscape names here. The surface takes its own presses, so pressing what a dialog holds never dismisses it.
  • A drawer is a sheet rather than a panel at the side of the page, because a panel at the side of a phone has nowhere to be. A top or bottom placement is already the edge a sheet comes from and is kept; a side placement resolves to the sheet, which the shared class dictionaries state. The movement is the platform's slide, from the edge it was given.
  • A tooltip is shown on a long press, because there is no pointer to rest on the trigger, and the same delay the web counts is the length of the platform's long press. Both halves of that delay work on the device: the hint stays for the close half after the press ends, a number states both halves at once, and an object with no close hides the hint the moment the thumb lifts. It is placed against the platform's own measurement of its trigger, and the placement reads exactly as the web's does, a side placement resolving to the vertical direction it reads from, because a hint beside a control has nowhere to be on a screen a thumb is already holding. Because a control may claim the press for itself, the hint is also stated as the trigger's accessibility hint, so a reader using assistive technology is told it without having to find the gesture, but that statement is text only: the platform's hint cannot hold a node, so it is made only when content is a string, and node content is read once the hint itself is open.

Two of the overlays name options the platform leaves out, and the modules say so: a portal and a layer order are about where something sits in a document, and the platform has no document.

The shell came last, and it is the increment that closes the matrix. A Navbar, a Sidebar and the three compositions that arrange them are the components a whole screen is built out of, so what the platform decides about them is about a screen rather than about a document:

  • A bar scrolls its row of destinations rather than opening a disclosure, because a browser's bar is a single line it cannot scroll sideways and a platform's is not. The disclosure's three options, mobileOpen, onMobileOpenChange and mobileLabel, resolve through the shared contract and change nothing, and where the web marks the current destination with aria-current the platform states the same fact as the control's selected state. The navbar module also publishes TabBar, the platform's own second half of a navigation: the same destinations and the same states, at the edge a thumb reaches, which the web needs no equivalent for because its links are already in the bar.
  • A sidebar collapses to a rail exactly as it does on the web, and a collapsed row explains itself through the framework's native Tooltip, which the platform shows on a long press rather than while a pointer rests on it. A row is the framework's own Link, so a consumer keeps its routing. The width transition and the pointer cursor the web declares resolve through the shared contract and state nothing, because a class cannot promise movement or a cursor the platform does not have.
  • SidebarLayout and DocsLayout are stacked screens. The shell asks useBreakpoint once and states the direction it means, stacked on a phone, and beside the content from lg, with the column placed outside the consumer's scrolling region, which is the platform's own way of keeping it in view, so stickySidebar, stickyNavigation and the contents' stickyToc resolve and change nothing. A documentation page's three regions become three stacked sections in the order the web's own flow gives them at its narrow widths, each headed with the name the web gives its landmark, because a platform screen has no landmark vocabulary but does let a region be titled.
  • AuthLayout is what fills the height the platform gives the screen: the form is centred in it, the media column is decided once by useBreakpoint, and nothing scrolls, because the keyboard and the reading order of a platform screen are the screen's own business.

The queue in scripts/native-parity.json is empty as a result: every component the matrix classifies for native is implemented, so platform support is a stated fact rather than something a developer discovers by trying, and nothing is left for a later increment.

@asheeui/native is published, on the same version line as the web renderer: @asheeui/native 2.5.0 and @asheeui/web 2.5.0 are the same release, built against @asheeui/core 2.3.1. An application installs it directly, or reaches it through the asheeui umbrella, which routes an import to this package on a React Native platform. The matrix and the parity check above are the record of what it provides: every component the matrix classifies for native is in the release, and nothing is left queued.

The version line is each package's own rather than a promise that the two renderers always move together. They share 2.5.0 because this release changed both; a later release of one is not a release of the other, and the release table keeps the current version of each.

One rule keeps a consumer's install resolvable: a published package may depend only on published packages. @asheeui/web depends on @asheeui/core and on @floating-ui/react, clsx and tailwind-merge, and @asheeui/native depends on @asheeui/core and on its peers, which is checked by reading the manifest of the released tarball rather than the one in the repository.

Platform support, per component

The compatibility matrix is data, and every public component has exactly one classification:

Classification Meaning Examples
Shared One contract, and the platform implementation is the platform's Button, Text, Card, Badge, Input, Chip, Spinner, Skeleton, PinInput, Form, Radio, Switch, Stack, Section, Grid, EmptyState, Alert, LoadingState, ErrorState, SearchInput, Stepper
Shared API, separate implementation The concept is shared and the platform behaviour differs, deliberately Modal (platform modal), Drawer (bottom sheet), Tooltip (long press), Tabs, Table (rows, not a table), DataTable (a list), Centered (centres on the axis and claims the height, but has no contained width, the platform's own screen width is the measure), Page (screen with safe areas), Pagination (load more), Split (the same two panes, decided from the window rather than by a media query), FileUpload (the document picker), Calendar (one surface at a time, and the time is stepped rather than typed), SidebarLayout and DocsLayout (a stacked screen), Navbar (a header with a tab bar) and Sidebar (the same list, collapsing to a rail)
Web only The concept has no meaningful native equivalent Breadcrumb (native uses a titled header), ResizableScreen (a desktop idiom)
Not applicable The component compensates for a browser constraint the platform solves itself Keyboard (the platform has one)

The matrix is enforced by a test, so a component cannot be added to the framework without a platform decision being recorded for it.

Keeping the components in step

A stored classification is a promise, and a promise that nothing checks decays. So the matrix is also read by a guard:

pnpm check:native-parity          # verify, exit non-zero on drift
pnpm check:native-parity --list   # print the whole expectation table

The guard reads the matrix from its source, the moment an entry is written it is seen, and it asks what @asheeui/native publishes rather than what its file names suggest, by following the package's export * statements from its entry point. It then fails in three cases:

  • A component the matrix promises for native is neither exported nor queued.
  • A component is exported but still sits in the queue.
  • The queue names a module the matrix does not promise for native.

The queue itself is scripts/native-parity.json. It has two halves: overrides, which names the native component where native publishes a different name from the matrix's module (typography ships as Text, table and data-table ship as RowList, toast ships as ToastProvider), and pending, which is what is left to implement. The queue is a ratchet and may only shrink: implementing a queued component fails the check until its entry is removed, so it cannot quietly become a list of what nobody got round to. It is empty now, and it stays in the file because the next component added to the matrix either ships for both platforms or is recorded there. CI runs the guard, so a component cannot ship for one platform alone by accident.

The layout kit and responsiveness

The layout primitives exist on native with the same props and the same configuration cascade as the web, except where the table below records a difference:

Primitive What it decides natively
Container A maximum width and a gutter, for a tablet or a desktop-sized window
Section Vertical rhythm and a background band
Stack, HStack, VStack One axis, a token gap, alignment, distribution and wrapping
Grid A column count per breakpoint, resolved from the window
Centered One block in the middle, on the axis you choose, the window's own width is the measure, so there is no contained width to state

Responsiveness is a value rather than a class prefix, because the platform reports the window rather than matching a media query:

const { isAtLeast } = useBreakpoint();

<Stack direction={isAtLeast("md") ? "row" : "column"} gap="lg">
  <Card title="Inbox" description="12 unread" />
  <Card title="Sent" description="348 this month" />
</Stack>;

useBreakpoint() is exported under the same name, with the same shape, by @asheeui/web — the platform's hook reads its window, the web's measures the window after mount — so a component that has to choose between two arrangements asks once and runs on both. The rule itself (resolveBreakpoint) and the state's interface (BreakpointState) live in @asheeui/core, so neither renderer owns the boundaries.

Grid uses the same vocabulary for its column count:

<Grid columns={1} columnsMd={2} gap="lg">
  <Card title="Inbox" description="12 unread" />
  <Card title="Sent" description="348 this month" />
</Grid>;

Two rules keep this honest. The breakpoints are the shared ones, so md means 768 density-independent pixels on both platforms. And the inheritance rule is a pure function (resolveGridColumns), tested at every breakpoint, so a count stated at a smaller breakpoint carries to the larger ones exactly as it does on the web.

The kit avoids responsive class prefixes on purpose. A prefix would depend on NativeWind's breakpoints happening to agree with the framework's, and the framework states its breakpoints as data, so a component reads them instead.

Configuration

The native provider resolves configuration through the same cascade as the web implementation: instance prop, then the component's configuration, then the platform default, then the value the component documents. It also accepts the same palette and theme the web renderer does, and paints them: color holds the palettes per theme name, defaultTheme chooses one, and "system" follows the device's colour scheme.

import { AsheeUIProvider, Button } from "@asheeui/native";

export function App() {
  return (
    <AsheeUIProvider
      config={{
        color: {
          light: { primary: "#047857", background: "#FFFFFF" },
          dark: { primary: "#34D399", background: "#0A1410" },
        },
        defaultTheme: "system",
        defaultVariant: "bordered",
        defaultRadius: "sm",
        components: { button: { size: "lg", fullWidth: true } },
      }}>
      <Button onPress={save}>Save invoice</Button>
    </AsheeUIProvider>
  );
}

AsheeUIProvider is the provider under the name the umbrella's web branch uses; AsheeNativeProvider is the same component. The device accepts the web renderer's own ExternalConfig — plus the platform-only defaultSize — so a configuration object typed that way describes both, and AsheeConfig is the same type under the name asheeui resolves per platform. The component sections the shared configuration carries are one type; the sections the web package declares in its own files (card, typography, container, the field family and the layout ones) are added by the device's package, so a native-only installation can configure them and a web-typed object is still accepted.

Rendering a framework component outside the provider throws rather than defaulting silently, because a missing provider is a setup mistake and a silent default would hide it. The provider also claims the height it is given, which is why it belongs at the root of the tree.

Setup

The native renderer supplies components, not a platform. Everything below adapts the application's own configuration rather than replacing it, and the steps are in the order the pipeline runs: Babel turns JSX into elements, Metro hands NativeWind the stylesheet, Tailwind compiles the classes it can read, and the provider supplies the configuration.

1. Install the peer dependencies

npx expo install nativewind react-native-reanimated react-native-safe-area-context
npm install @asheeui/native

react-native-reanimated is what NativeWind's press and layout transitions ask for, and react-native-safe-area-context is what the page shell measures its safe areas with. Install the React Native copy the application already has rather than a second one.

Do not install react-native-css-interop yourself. NativeWind 4.2.7 brings its own copy, and that copy is the half that turns a compiled class into a style. A pin of your own — a leftover 0.2.1, a tutorial's instructions, a lockfile from another project — resolves instead of the copy NativeWind brings. The Tailwind compile still succeeds, the bundle still contains every class name, and every component renders unstyled with no warning anywhere. If a dependency of the application's pins it, match the version NativeWind ships.

2. Babel

// babel.config.js
module.exports = function (api) {
  api.cache(true);

  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
      "nativewind/babel",
    ],
  };
};

Two presets, and the order matters. babel-preset-expo is the platform's own — the JSX transform, the new architecture, the react-native resolver — and NativeWind's preset comes after it, because the CSS transform runs on the output of the JSX transform rather than instead of it. NativeWind's preset is the half that points JSX at the interop runtime (it installs @babel/plugin-transform-react-jsx with importSource: "react-native-css-interop", and rewrites React.createElement the same way), and jsxImportSource: "nativewind" states the same thing to the platform's preset, which is why NativeWind's documentation asks for both. It also adds the Reanimated plugin, so an application does not have to list it again.

The package's own compiled modules emit through that runtime themselves, so the framework's components are styled even without this configuration; the application's own JSX would not be.

3. Metro, and the stylesheet it compiles

// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");

module.exports = withNativeWind(getDefaultConfig(__dirname), {
  input: "./global.css",
});
/* global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
// App.tsx
import "./global.css";

The stylesheet has to be both configured and imported. One that is named in metro.config.js and never imported is a bundle with no styles in it, which is the first thing to check when nothing is styled.

4. Tailwind: the package's preset

// tailwind.config.js
const asheeui = require("@asheeui/native/tailwind-preset");

module.exports = {
  presets: [asheeui],
  // The application's own files, then the framework's — those are already absolute.
  content: ["./src/**/*.{ts,tsx}", ...asheeui.content],
};

The preset is three things in one:

  • NativeWind's preset, so the configuration compiles for the platform rather than for a stylesheet.
  • The eight colour roles the components reference by name — bg-background, text-foreground, bg-primary, border-border, bg-secondary/40 — each stated as the custom property it reads, rgb(var(--ashee-<role>) / <alpha-value>). The values are the application's and come from the configuration: the provider resolves color and defaultTheme and paints the result, so the palette is stated once for both renderers rather than copied into this file.
  • The package's own compiled modules in content. Tailwind compiles only a class it can read in a file it is allowed to scan, and the framework's class strings are in the package's dist, outside the application's directory. asheeui.content is an absolute glob because the installed location is not node_modules/@asheeui/native: pnpm, Bun's isolated installs and npm with a different peer set each place it somewhere else, and how many directories up that is depends on how deeply the application itself is nested.

The spread is not decoration. Tailwind v3 does not merge a preset's content into a configuration that states its own — the application's list replaces it — so the framework's glob has to reach the application's array. A content array that names only the application's own files compiles none of the framework's classes, and the screen is half styled rather than unstyled, which is harder to spot.

Do not state the roles here as colours. An application that writes theme.extend.colors: { primary: "#047857" } overrides the preset's expression, which fixes that role at build time and stops it following the theme — so defaultTheme and color would paint every role except that one. A build-time palette is a legitimate choice; it just is not the configuration's palette, and the two do not mix.

A role in the configuration must be a hex colour or an rgb()/rgba() value. The platform applies a theme as channels (rgb(var(--ashee-primary) / <alpha-value>)), so a value it cannot read as channels is an error naming the role rather than a surface that paints nothing, and a role is opaque: the alpha of an rgba() value is ignored, because the framework's own classes carry their alpha (bg-secondary/40).

The same eight roles are what @asheeui/web resolves from its --ashee-* custom properties, which is what makes one palette one design language rather than two copies that drift.

5. The provider

// App.tsx
import { AsheeUIProvider } from "@asheeui/native";

export function App() {
  return (
    <AsheeUIProvider config={{ defaultTheme: "system" }}>
      <Screen />
    </AsheeUIProvider>
  );
}

Rendering a framework component outside the provider throws rather than defaulting silently, because a missing provider is a setup mistake and a quiet default would hide it. With no color the provider paints the framework's own light and dark palettes, so a screen is themed before the application states anything.

When nothing is styled

The framework's components carry no fallback style, deliberately: a fallback would hide a broken pipeline behind a screen that looks merely plain. So the failure has one shape — the right words, the right structure, no design at all: no page padding, no card surfaces, no borders, no type scale, and the status bar sitting over the first heading. These are the causes, in the order they are worth checking:

  1. The stylesheet is never imported. global.css is named in metro.config.js and not imported by the application. Step 3.

  2. The framework's modules are not in content. The application's content names its own files and not asheeui.content, so the framework's classes compile to nothing. Half the screen is then styled — the application's own components are — and the framework's are not.

  3. react-native-css-interop is pinned away from the version NativeWind ships. The compile succeeds, the classes are in the bundle, and nothing is applied.

  4. A role is stated as a colour in tailwind.config.js. The preset states each role as the custom property the provider paints; a hex in theme.extend.colors replaces that expression, so the role is fixed at build time and stops following color and defaultTheme. Step 4. (A role that is missing from the preset's list compiles to nothing, which looks the same: role classes do nothing while spacing and layout still work.)

  5. The package's compiled modules do not emit through NativeWind's runtime. This was a defect of @asheeui/native 2.5.0 and older, and it is fixed: the artifact imported react/jsx-runtime, so NativeWind's Babel transform — which rewrites JSX and React.createElement, and a compiled module contains neither — never saw the framework's className. Upgrade the package. A consumer who cannot upgrade yet can restore the runtime with a Metro alias scoped to the package, so React's own runtime is untouched:

    // metro.config.js — @asheeui/native 2.5.0 and older only
    const RUNTIMES = new Set(["react/jsx-runtime", "react/jsx-dev-runtime"]);
    const PACKAGE_DIST = /@asheeui[\\/]native[\\/]dist[\\/]/;
    const base = config.resolver.resolveRequest;
    
    config.resolver.resolveRequest = (context, moduleName, platform) => {
      const resolve = base ?? context.resolveRequest;
    
      return RUNTIMES.has(moduleName) &&
        PACKAGE_DIST.test(context.originModulePath ?? "")
        ? resolve(context, "nativewind/jsx-runtime", platform)
        : resolve(context, moduleName, platform);
    };

The provider checks the pipeline itself, so a development build does not have to be diagnosed by hand: once it has mounted it compares the runtime its own components use against React's, asks NativeWind whether the Babel transform, the stylesheet data and its preset are in place, and warns once with the cause it found. The same diagnosis is available as data:

import { verifyNativeWind } from "@asheeui/native";

const report = verifyNativeWind();

if (!report.ok) {
  for (const check of report.checks.filter((check) => !check.ok)) {
    console.log(check.summary, check.remedy);
  }
}

Development and testing

Each platform is tested with its own tooling, and neither suite pretends to be the other:

Platform Runner Why
Web Vitest and Testing Library, on a DOM The web implementation's behaviour is DOM behaviour
Native Jest with React Native's preset and the React Native Testing Library The native implementation's behaviour is the platform's: roles, accessibility states, press interaction

Two properties of the native test environment are worth knowing before writing a native test:

  • The React Native Testing Library is asynchronous: renders and events are awaited.
  • Queries are about the platform's accessibility tree, so a component is found by its role and asserted through accessibilityState rather than through a DOM.

Run one platform's tests on its own:

pnpm --filter @asheeui/native test     # native
pnpm --filter @asheeui/web test             # web
pnpm --filter @asheeui/core test     # the shared layer

One check is not a test, because it reads build output rather than sources:

pnpm --filter @asheeui/native build
pnpm check:native-artifact

It asserts that no published module emits elements through react/jsx-runtime, and that at least one emits through the interop runtime. The native suite cannot make that assertion: it compiles this package's own sources, which contain JSX, so its transform wraps the JSX and the components are styled in tests. The published artifact contains no JSX at all, which is why the suite can be green while every consumer's screen is unstyled — that is exactly the defect the check exists for, and CI runs it after the build.

A composed page

A page a web editor composed is rendered on a device by @asheeui/puck. The package holds one set of block specs, the label a builder shows, the fields it fills in and the value each block starts as, and two drawings for each block, so a page cannot mean one thing in the editor and another on a screen:

import { PuckPage } from "@asheeui/puck";

export function PublishedPage({ page }) {
  return <PuckPage data={page} />;
}

What the platform renders is the same component set documented above, reached through the same provider. Two things are deliberately absent. There is no editor: composing is a web concern, and the platform is where a composed page is shown. And the package imports nothing from @puckeditor/core, which is what lets a native bundle reach it at all, the editor is an optional peer the web half alone needs.

One arrangement differs and is stated rather than hidden. A two-column block offers a ratio on the web, where a stylesheet expresses one; the platform resolves its own window and offers whole column counts, so the block draws the framework's two columns once there is room for them.

Styling rules on native

NativeWind compiles the classes it can read in the source, exactly as Tailwind does on the web. Two consequences follow, and both are non-negotiable in this codebase:

  1. Every class is a complete, static string in a styles module. A class assembled at runtime produces no styling at all.
  2. Density is a design-language decision rather than a per-component one. A control is at least as tall as the shared touch target, and @asheeui/core states and tests that value.

Animation follows the same rule, and Spinner is the case that sets it: the shared layer states the duration and the classes, and the platform states the mechanism. NativeWind cannot register a component that animates itself, so motion here is React Native's Animated driven on the native thread (useNativeDriver: true), the view that moves carries nothing else, and the child inside it carries the classes, the props and the accessibility. The ring is drawn from theme classes rather than from a platform activity indicator, because a platform control takes a colour value while the framework's contract names a colour role: a control that cannot resolve the role would accept the prop and ignore it.

A third rule belongs to the build rather than to a component, and it is the one a consumer sees before any other. NativeWind does not read className at runtime: its Babel transform turns JSX into elements of its own runtime, so a className becomes a style. A compiled module contains no JSX and no React.createElement, which means a consumer's Babel has nothing to rewrite in it and the runtime it imports is the only thing that can route it. The published artifact therefore emits through nativewind/jsx-runtime (jsxImportSource in tsconfig.build.json), and pnpm check:native-artifact fails the build if that is ever lost — because the failure it prevents is a correct application with every framework component unstyled, and nothing thrown to say so.

The colour rule follows from the same reasoning. A class names a role, not a colour: bg-primary compiles to rgb(var(--ashee-primary) / <alpha-value>), and the provider paints --ashee-primary from the configuration's palette, so the same class renders the application's colour, and the same palette renders on both platforms. That is why no component's class string may name a raw Tailwind colour, and why a consumer who states theme.extend.colors trades the runtime palette for a build-time one.

Accessibility on native

Accessibility is a property of each component rather than a later addition:

  • Components carry the platform's roles (accessibilityRole).
  • State is announced through accessibilityState, so a disabled or busy control is reported as disabled or busy.
  • Decorative elements inside a control are hidden from assistive technology, so the control keeps one stable accessible name.
  • Touch targets respect the shared minimum; a control that misses it is a defect rather than a style choice.

What is deliberately not here

Not ported Why
Puck's editor It is a web authoring concern, and a block editor for a device would be a different product
A framework-wide animation system Motion belongs to the component that needs it, and a general layer would be speculation
The web's table, breadcrumb and resizable-split components Their native equivalents are lists, a titled header and a platform split view
The web package's source Sharing source would give one platform the other's behaviour, which is what this architecture exists to avoid