Writing a shared screen
The rules that keep a screen portable, the platform differences a shared file can state on purpose, and the escape hatches when it cannot.
A shared screen is a component that imports asheeui and nothing platform-specific. It renders in a
browser and on a device without knowing which one it is on. This page is the set of rules that makes
that true, and the places where a difference is allowed.
The reference is the shared kit in the monorepo blueprint: seven screens, one theme, two shells.
The three rules
- Import the umbrella.
asheeuionly. Never@asheeui/web,@asheeui/native,react-domorreact-nativein a shared file. A renderer import is a compile error on the other platform, and that error is the boundary being useful. - Use the framework's primitives. No
div,span,p,button,View,TextorPressable.Typographyis the text, the layout kit is the box,Buttonis the press,Imageis the picture. A raw element is a style decision for one platform. - State the contract, not the implementation. A screen takes data and callbacks; the shell owns
the router, the URL and the platform's navigation.
onOpenWorkout(id)is portable;router.navigate("/workouts/" + id)is not.
The screen's props
export interface ScreenProps {
onNavigate: (route: "today" | "workouts" | "nutrition" | "progress" | "settings") => void;
onOpenWorkout: (id: string) => void;
}
export function DashboardScreen({ onNavigate, onOpenWorkout }: ScreenProps) {
return (
<VStack gap="xl">
<ScreenHeading eyebrow="Good morning" title="Today" onAction={() => onNavigate("workouts")} />
<StatGrid />
<Button onPress={() => onOpenWorkout("lower-body")}>Start lower body power</Button>
</VStack>
);
}
The shell maps the callbacks to its own router. The web shell uses useNavigate; the native shell uses
a state machine, Expo Router, or whatever the application already has. The screen never sees either.
What is portable
The complete list is the portable surface. In practice:
| Need | Portable |
|---|---|
| Text | Typography (or Text, the same component) |
| Layout | Page, Section, Container, Grid, Stack, HStack, VStack, Centered, Split |
| A control | Button, Link, Card, Badge, Chip, Avatar, Image |
| A form | the field family, Form, PasswordInput, PinInput, Stepper |
| A list | Table, DataTable (the native branch draws them as rows) |
| Feedback | Alert, ToastProvider + useToast, EmptyState, ErrorState, LoadingState, Skeleton, Spinner |
| Disclosure | Tabs, Accordion, Carousel, Modal, Drawer, Tooltip |
| Theme | useTheme, THEME_STORAGE_KEY, ThemeSelection |
| Types | Color, Radius, Size, Space, Variant, ThemeName, AsheeConfig |
RowList, TabBar, Breadcrumb, ResizableScreen and the keyboard family are deliberately not on
that list. They belong to one renderer, and a shared screen that wants one is telling you the screen
belongs to that platform.
Events
The portable event names are onPress and onChange:
<Button onPress={save}>Save</Button>
<Input
label="Email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
onClick also compiles on the web and is accepted on a device (it is the web event name, not a second
handler). onChangeText is the raw native event; a shared screen should use onChange so the same body
runs on both.
The differences a shared file may state
Some differences are the product's, not the platform's, and a shared screen can state them:
The layout at a breakpoint
import { Grid, useBreakpoint } from "asheeui";
const { isAtLeast } = useBreakpoint();
<Grid columns={1} columnsMd={isAtLeast("md") ? 2 : 1} gap="lg">…</Grid>;
The same code runs on both: the web's hook measures the window, the device's reads it. A responsive
class prefix (md:grid-cols-2) is a web-only decision and compiles to nothing on a device.
A count that differs by platform
import { Platform } from "react-native"; // only in a shell, never in the kit
If the kit needs a platform decision, it should take it as a prop:
<WorkoutList columns={compact ? 1 : 3} />
The shell decides compact; the kit renders it. That keeps the platform import in the shell.
A field's helper text
The native field family has the same label, description and message as the web. The one difference
worth stating in the kit is the search field: SearchInput has no placeholder on a device, so the
label and description carry the prompt.
<SearchInput label="Search the library" description="Name, focus or coach" … />
The escape hatch
When one screen genuinely needs a platform API, do not hide it in the kit. Split the screen:
packages/kit/src/screens/studio.tsx the page, the data, the layout
apps/web/src/screens/studio-editor.tsx the Puck editor (web-only)
apps/mobile/src/screens/studio.tsx the native renderer (already native)
The kit exports the common part; each application owns the platform part. The alternative — a
Platform.OS branch inside the kit — compiles, but it moves the platform into the shared file and the
next screen copies it.
The repository's studio is exactly this split: the shared StudioScreen renders a Puck page on both
platforms, and the editor exists only on the web.
Theming without a stylesheet
A shared screen states colours as roles, not values:
<Card className="bg-primary/10 border-primary/30">
<Typography role="heading-sm">Push volume</Typography>
</Card>
bg-primary compiles to rgb(var(--ashee-primary) / <alpha-value>) on both platforms; the provider
paints the variable from the configuration. A hex inside the kit would be a colour that cannot follow
the theme and a divergence waiting to happen.
className is allowed; it is the shared subset of Tailwind that both compilers understand. What is not
allowed is the vocabulary only one compiler understands: hover:, focus:, the responsive prefixes,
shadow-*, ring-*, z-*, transition-*. The native linter fails on them.
Testing a shared screen
Typecheck is the first test: the kit's check-types compiles it against both renderers (see
the monorepo guide). A screen that passes both
is portable by construction.
The second test is the screen's behaviour on each platform's own runner:
| Platform | Runner | What it proves |
|---|---|---|
| Web | Vitest + Testing Library on the DOM | the browser's roles, events and focus |
| Native | Jest + React Native Testing Library | the accessibility tree and the platform's press |
Do not try to run one test suite on both. The two platforms' queries are different languages, and a test that mocks the platform away proves nothing about either.
A checklist
- Imports only
asheeui(and@asheeui/puckfor a composed page). - No DOM or React Native primitive.
- Navigation, URLs and the router are callbacks.
- Colours are roles, not hex values.
-
classNameuses only the shared vocabulary. - Responsive decisions use
useBreakpointor props, not prefixes. - Typechecks with the web and the native conditions.
- The platform-specific part, if any, lives in each shell.