Skip to contentAsheeUIAsheeUI

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

  1. Import the umbrella. asheeui only. Never @asheeui/web, @asheeui/native, react-dom or react-native in a shared file. A renderer import is a compile error on the other platform, and that error is the boundary being useful.
  2. Use the framework's primitives. No div, span, p, button, View, Text or Pressable. Typography is the text, the layout kit is the box, Button is the press, Image is the picture. A raw element is a style decision for one platform.
  3. 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/puck for a composed page).
  • No DOM or React Native primitive.
  • Navigation, URLs and the router are callbacks.
  • Colours are roles, not hex values.
  • className uses only the shared vocabulary.
  • Responsive decisions use useBreakpoint or props, not prefixes.
  • Typechecks with the web and the native conditions.
  • The platform-specific part, if any, lives in each shell.