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:
PinInputreadssanitizePinValuefrom@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
statusrole, so@asheeui/corestates the relation between the two vocabularies once (NATIVE_ANNOUNCEMENT_LIVE_REGIONandresolveNativeAnnouncementRole), 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
loopwraps only the autoplay here: a scroll view that teleported its content back to the start would be lying about where the reader is.pauseOnHoverbecomes "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
closeOnEscapenames 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
delaythe web counts is the length of the platform's long press. Both halves of that delay work on the device: the hint stays for theclosehalf after the press ends, a number states both halves at once, and an object with noclosehides 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 whencontentis 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,onMobileOpenChangeandmobileLabel, resolve through the shared contract and change nothing, and where the web marks the current destination witharia-currentthe platform states the same fact as the control's selected state. The navbar module also publishesTabBar, 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 ownLink, 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. SidebarLayoutandDocsLayoutare stacked screens. The shell asksuseBreakpointonce and states the direction it means, stacked on a phone, and beside the content fromlg, with the column placed outside the consumer's scrolling region, which is the platform's own way of keeping it in view, sostickySidebar,stickyNavigationand the contents'stickyTocresolve 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.AuthLayoutis what fills the height the platform gives the screen: the form is centred in it, the media column is decided once byuseBreakpoint, 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 resolvescoloranddefaultThemeand 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'sdist, outside the application's directory.asheeui.contentis an absolute glob because the installed location is notnode_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:
The stylesheet is never imported.
global.cssis named inmetro.config.jsand not imported by the application. Step 3.The framework's modules are not in
content. The application'scontentnames its own files and notasheeui.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.react-native-css-interopis pinned away from the version NativeWind ships. The compile succeeds, the classes are in the bundle, and nothing is applied.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 intheme.extend.colorsreplaces that expression, so the role is fixed at build time and stops followingcoloranddefaultTheme. 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.)The package's compiled modules do not emit through NativeWind's runtime. This was a defect of
@asheeui/native2.5.0 and older, and it is fixed: the artifact importedreact/jsx-runtime, so NativeWind's Babel transform — which rewrites JSX andReact.createElement, and a compiled module contains neither — never saw the framework'sclassName. 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
accessibilityStaterather 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:
- Every class is a complete, static string in a styles module. A class assembled at runtime produces no styling at all.
- 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/corestates 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 |