pyreon

@pyreon/primitives

One vocabulary, three outputs. Write a component once with these primitives: on the web they render real DOM; on iOS and Android the PMTC compiler reads the same .tsx and emits SwiftUI and Jetpack Compose. On native targets the import is only there for TypeScript, and the web implementation is never called.

bun add @pyreon/primitives @pyreon/core @pyreon/reactivity @pyreon/runtime-dom
Quick start — live

What is in the package

GroupComponentsLowers natively via
LayoutStack, Inline, Layer, Scroll, Spacerdedicated emitters
ContentText, Heading, Image, Audio, Video, Icondedicated emitters
InteractionButton, Press, Link, Gesturededicated emitters
InputField, Toggle, Modaldedicated emitters
AnimationTransition, TransitionGroupdedicated emitters
Web contentWebViewnative web host
Escape hatchesWeb, NativeIOS, NativeAndroidper-target branch

The first four groups are the 18 canonical primitives, the UI vocabulary the compiler treats as its own. The compiler's list lives inpackages/native/compiler/src/canonical-primitives.ts (CANONICAL_PRIMITIVES), and a test checks that it matches what this package exports. The animation wrappers, WebView and the escape hatches also compile to all three targets, but each has its own emitter, so they are listed separately.

The package also exports init (router wiring for <Link>, see below),defineNativeModule / useNativeModule (add your own Swift/Kotlin code, seeMultiplatform → escape hatches) and connectWebHost (the page-side half of <WebView>).

Shared props

Tokens instead of pixels

Layout primitives (Stack, Inline, Layer, Scroll) take token values, and every target resolves them to the same numbers:

PropValuesResolves to
padding paddingX paddingY margin marginX marginY (and gap on Stack/Inline)0–9 or 'xs' | 'sm' | 'md' | 'lg' | 'xl'index 0 1 2 3 4 5 6 7 8 9 → 0 4 8 12 16 20 24 32 40 48; names → xs 4, sm 8, md 12, lg 16, xl 24 (px on web, pt on iOS, dp on Android)
background, and color on Text/Heading/Icon'text' | 'surface' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'muted'#111827 #ffffff #2563eb #6b7280 #16a34a #d97706 #dc2626 #9ca3af
radius'none' | 'sm' | 'md' | 'lg' | 'full'0 4 8 16 9999

Raw pixel values are not accepted. For a one-off value use style on the web, or a platform branch.

Accessibility, test ids and HTML attributes

Every primitive except WebView accepts these, and each target converts them:

PropWebiOSAndroid
accessibilityLabelaria-label.accessibilityLabel(…)semantics { contentDescription = … }
accessibilityHiddenaria-hidden="true".accessibilityHidden(true)clearAndSetSemantics { }
accessibilityRole ('button' | 'image' | 'header')role.accessibilityAddTraits(.isButton / .isImage / .isHeader)semantics { role = … } / heading()
data-testidattribute.accessibilityIdentifier(…)Modifier.testTag(…)
other data-*, aria-*, id, class, styleforwarded to the elementignoredignored

Prefer the three accessibility* props over raw aria-*, because only they reach the native accessibility trees.

Transforms

Every primitive except WebView also accepts four transform props. They move, scale and rotate the rendered element about its centre without affecting layout, so siblings do not reflow. Values can be signal reads and update in place, which is how Gesture values drive content.

PropWebiOSAndroid
translateX, translateYtransform: translate(…px, …px).offset(x:y:)Modifier.offset(x, y)
scaletransform: scale(…).scaleEffect(…)Modifier.scale(…)
rotate (degrees, clockwise)transform: rotate(…deg).rotationEffect(.degrees(…))Modifier.rotate(…)

The order is the same on every target: rotate, then scale, then translate. A translation is therefore not multiplied by the scale. On iOS a value read from an integer signal is converted to CGFloat/Double for you.

Signals

Pass signal reads directly, as you would anywhere else in Pyreon:value={name()}, open={isOpen()}, disabled={busy()}. The compiler keeps them reactive on every target. Handlers write back with .set(...).

Layout

Stack

A flex container. Children run top to bottom by default.

PropTypeDefault
direction'column' | 'row''column'
align'start' | 'center' | 'end' | 'stretch' (cross axis)—
justify'start' | 'center' | 'end' | 'between' | 'around' | 'evenly'—
gapspace token—
wrapbooleanfalse
layout propspadding, margin, background, radius, …—
// @check
import { Stack, Text } from '@pyreon/primitives'

export function Card() {
  return (
    <Stack gap="md" padding={4} background="surface" radius="md" align="start">
      <Text weight="bold">Title</Text>
      <Text color="muted">Body copy</Text>
    </Stack>
  )
}
  • Web: <div> with display:flex; flex-direction: column | row.

  • iOS: VStack (or HStack for direction="row") withalignment:/spacing:, then .padding / .background / .cornerRadiusmodifiers. margin becomes an outer .padding.

  • Android: Column / Row with Arrangement.spacedBy(…) and anAlignment, then Modifier.padding / .background / .clip(RoundedCornerShape).

  • Not on native: justify and wrap are ignored on iOS and Android, and the compiler warns about it. SwiftUI stacks have no distribution setting and no wrapping. Use <Spacer /> between children to push them apart.

Inline

A horizontal Stack. It takes the same props as Stack except direction.

// @check
import { Inline, Button, Text } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function Toolbar() {
  const count = signal(0)
  return (
    <Inline gap="sm" align="center">
      <Text>Count: {count()}</Text>
      <Button onPress={() => count.set(count() + 1)}>+1</Button>
    </Inline>
  )
}
  • Web: <div style="display:flex; flex-direction:row">.

  • iOS: HStack. Android: Row.

  • Same native limits as Stack: justify and wrap are ignored with a warning.

Layer

Stacks its children on top of each other; later children are in front.

PropType
align'start' | 'center' | 'end' | 'stretch'
layout propspadding, margin, background, radius, …
// @check
import { Layer, Image, Text } from '@pyreon/primitives'

export function Poster() {
  return (
    <Layer align="center">
      <Image src="https://picsum.photos/320/180" alt="Background" width={320} height={180} />
      <Text color="surface" weight="bold">On top</Text>
    </Layer>
  )
}
  • Web: <div style="position:relative; display:grid">, with align mapped to place-items.

  • iOS: ZStack(alignment:). Android: Box(contentAlignment = …).

  • Differs on web: native children overlap automatically. On the web, two ordinary children sit in separate grid rows. To overlap them on the web, give the front child position: absolute (the Layer is its positioning parent).

Scroll

A scrollable area, vertical unless you say otherwise.

PropTypeDefault
axis'vertical' | 'horizontal''vertical'
layout propspadding, margin, background, radius, …—
// @check
import { Scroll, Stack, Text } from '@pyreon/primitives'

export function LongList() {
  return (
    <Scroll>
      <Stack gap="sm" padding={4}>
        <Text>Row 1</Text>
        <Text>Row 2</Text>
        <Text>Row 3</Text>
      </Stack>
    </Scroll>
  )
}
  • Web: <div> with overflow-y:auto (or overflow-x:auto foraxis="horizontal").

  • iOS: ScrollView / ScrollView(.horizontal).

  • Android: Column(Modifier.verticalScroll(rememberScrollState())) orRow(Modifier.horizontalScroll(…)).

  • For long, data-driven lists use <For each by>, which lowers to a lazy list on native.

Spacer

Takes up the remaining space along the parent's direction. It has no props of its own.

// @check
import { Inline, Spacer, Text } from '@pyreon/primitives'

export function Header() {
  return (
    <Inline padding={3}>
      <Text weight="bold">Inbox</Text>
      <Spacer />
      <Text color="muted">12 unread</Text>
    </Inline>
  )
}
  • Web: <div style="flex:1 1 auto">.

  • iOS: Spacer(). Android: Spacer(Modifier.weight(1f)).

Content

Text

Inline text.

PropTypeNotes
size'xs' | 'sm' | 'md' | 'lg' | 'xl'12 14 16 20 24 px/pt/sp
weight'regular' | 'medium' | 'bold'web 400 / 500 / 700
colorcolor token
truncatebooleanone line with an ellipsis
fontstringname of a font bundled by the native assets step
// @check
import { Text } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function Greeting() {
  const name = signal('Ada')
  return (
    <Text size="lg" weight="bold" color="primary" truncate>
      Hello, {name()}
    </Text>
  )
}
  • Web: <span> with inline styles.

  • iOS: Text(…) with .font(.system(size:weight:)), .foregroundColor, and .lineLimit(1).truncationMode(.tail) for truncate. Text containing a value uses Text(verbatim:), so numbers are not reformatted per locale.

  • Android: Text(text = …, fontSize = …sp, fontWeight = …, color = …, maxLines = 1, overflow = TextOverflow.Ellipsis).

  • font needs the font bundled by the native assets step. Without it iOS falls back to the system font and the compiler warns.

Heading

A heading. level sets both the size and, on the web, the tag.

PropTypeDefault
level1–61
colorcolor token—
// @check
import { Stack, Heading, Text } from '@pyreon/primitives'

export function Section() {
  return (
    <Stack gap="sm">
      <Heading level={2}>Settings</Heading>
      <Text>Change how the app behaves.</Text>
    </Stack>
  )
}
LevelWebiOSAndroid (Material 2 typography)
1<h1> 32px.largeTitleh4
2<h2> 24px.titleh5
3<h3> 20px.title2h6
4<h4> 18px.title3subtitle1
5<h5> 16px.headlinebody1
6<h6> 14px.subheadlinebody2

iOS adds .bold(); the web uses font-weight: 700. On native the heading is a styled Text. If assistive tech should announce it as a heading, addaccessibilityRole="header".

Image

A bitmap image.

PropTypeDefault
srcstring (required)—
altstring (required)—
fit'cover' | 'contain' | 'fill' | 'none''cover'
width, heightnumber | string—
// @check
import { Image } from '@pyreon/primitives'

export function Avatar() {
  return <Image src="https://picsum.photos/96" alt="Profile photo" fit="cover" width={96} height={96} />
}

src means the same thing on every target: an http(s):// URL is loaded remotely, and a bare name like logo.png is a bundled asset.

  • Web: <img> with object-fit. Bare names are served from /assets/.

  • iOS: remote → AsyncImage(url:) with a clear placeholder; bundled →Image("logo") from the asset catalog. cover → .scaledToFill(),contain → .scaledToFit(), fill → .resizable(), none → natural size.alt becomes .accessibilityLabel.

  • Android: remote → Coil AsyncImage(model = …); bundled →Image(painterResource(pyreonDrawable("logo"))). fit → ContentScale.Crop / Fit / FillBounds / None. alt becomes contentDescription.

Audio

Sound playback. It has no visible UI: build your own controls from other primitives and drive them with these props.

PropTypeDefault
srcstring (required)—
autoPlaybooleanfalse
loopbooleanfalse
mutedbooleanfalse
volumenumber (0–1, clamped)—
onStatusChange(status: 'waiting' | 'playing' | 'paused') => void—
// @check
import { Audio, Text, Stack } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function Chime() {
  const status = signal('waiting')
  return (
    <Stack>
      <Audio src="https://example.com/chime.mp3" autoPlay muted onStatusChange={(s) => status.set(s)} />
      <Text>Audio is {status()}</Text>
    </Stack>
  )
}
  • Web: <audio> without controls. Bare names are served from /assets/.

  • iOS: PyreonAudioPlayer(url:…, engine: AVFoundationAudioEngine()).

  • Android: PyreonAudioPlayer(url = …, engine = Media3AudioEngine(…)).

  • On native, autoPlay, loop, muted and volume must be literal values. A value that changes at runtime is dropped with a warning, and a src that isn't a literal string is not lowered.

Video

Video playback.

PropTypeDefault
srcstring (required)—
autoPlay, loop, mutedbooleanfalse
controlsbooleantrue
width, heightnumber | string—
onStatusChange(status: 'waiting' | 'playing' | 'paused') => void—
// @check
import { Video } from '@pyreon/primitives'

export function Clip() {
  return (
    <Video
      src="https://example.com/clip.mp4"
      autoPlay
      muted
      loop
      controls={false}
      width={320}
      height={180}
    />
  )
}
  • Web: <video>. Browsers only allow muted autoplay reliably, so pairautoPlay with muted.

  • iOS: PyreonVideoPlayer(url:), which wraps AVKit's VideoPlayer, plus a.frame for the size.

  • Android: PyreonVideoPlayer(url = …), which wraps a Media3 ExoPlayer.

  • The same literal-value rule as Audio applies on native.

Icon

A named icon. The name is the same on every platform; each target maps it to its own icon set.

PropTypeDefault
namestring (required)—
size'sm' | 'md' | 'lg''md'
colorcolor tokeninherits the text colour on web
// @check
import { Inline, Icon, Text } from '@pyreon/primitives'

export function Rating() {
  return (
    <Inline gap="xs" align="center">
      <Icon name="star" color="warning" size="sm" />
      <Text>4.8</Text>
    </Inline>
  )
}
  • Web: <svg><use href="#star" /></svg>, sized 16 / 20 / 24 px. The package ships no icon set: add an SVG sprite with <symbol id="star">to your page once. Icons are aria-hidden unless you pass an aria-* attribute.

  • iOS: an SF Symbol, Image(systemName:), with .imageScale(.small / .medium / .large).

  • Android: a Material icon, Icon(imageVector = Icons.Filled.…), sized 16 / 20 / 24 dp.

  • The names with an entry on both platforms: add, arrow-back, arrow-down,arrow-forward, arrow-up, account, calendar, cart, check,check-circle, close, delete, edit, email, exit, face,favorite, favorite-outline, home, info, list, location, lock,menu, more, notifications, person, phone, play, refresh,search, send, settings, share, star, thumb-up, warning. Any other name is passed to iOS as a raw SF Symbol name and shows a warning placeholder on Android, and the compiler warns.

Interaction

Button

A button with the platform's own styling.

PropTypeDefault
onPress() => void (required)—
variant'primary' | 'secondary' | 'ghost' | 'danger''primary'
disabledbooleanfalse
// @check
import { Inline, Button } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function Actions() {
  const saving = signal(false)
  return (
    <Inline gap="sm">
      <Button onPress={() => saving.set(true)} disabled={saving()}>Save</Button>
      <Button variant="ghost" onPress={() => saving.set(false)}>Cancel</Button>
    </Inline>
  )
}
VariantWebiOSAndroid
primaryfilled blue <button>default ButtonButton
secondarywhite with a grey border.buttonStyle(.bordered)OutlinedButton
ghosttext only.buttonStyle(.plain)TextButton
dangerfilled red.buttonStyle(.borderedProminent).tint(.red)Button in colors.error

disabled becomes the disabled attribute on web, .disabled(…) on iOS andenabled = !… on Android. Note that primary on iOS is SwiftUI's default button, which is tinted text rather than a filled button.

Press

Makes anything tappable, with no button styling of its own. Use it for clickable cards and custom controls.

PropType
onPress() => void (required)
onLongPress() => void
onSwipeLeft, onSwipeRight() => void
disabledboolean
// @check
import { Press, Stack, Text } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function SwipeCard() {
  const last = signal('none')
  return (
    <Press
      onPress={() => last.set('tap')}
      onLongPress={() => last.set('long press')}
      onSwipeLeft={() => last.set('swipe left')}
      onSwipeRight={() => last.set('swipe right')}
    >
      <Stack padding={4} background="surface" radius="md">
        <Text>Last gesture: {last()}</Text>
      </Stack>
    </Press>
  )
}
  • Web: <div role="button" tabindex="0">. Enter and Space triggeronPress. A long press fires after holding for 500ms. A swipe is a mostly horizontal drag of at least 40px, and a swipe does not also count as a tap.

  • iOS: Button(action:) { … }.buttonStyle(.plain), plus aLongPressGesture(minimumDuration: 0.5) and a high-priority DragGesturefor the swipes (same 40pt rule).

  • Android: Box(Modifier.combinedClickable(onClick, onLongClick)) (orModifier.clickable) plus detectHorizontalDragGestures for the swipes (same 40dp rule).

Gesture

Reports continuous gestures (pan, pinch and rotate) so their values can drive content. Discrete gestures (tap, long press, swipe) belong on Press.

PropType
onPan(e: { dx, dy, x, y, phase }) => void
onPinch(e: { scale, centerX, centerY, phase }) => void
onRotate(e: { degrees, phase }) => void

Every value is cumulative since the gesture began: dx/dy are the translation so far, scale is 1 until the fingers move apart, and degreesis positive clockwise. x/y and centerX/centerY are in the region's own coordinates. Each gesture delivers one 'start', any number of 'change', then one 'end', including when the system cancels it. Pinch and rotate begin only once two pointers are down.

// @check
import { Gesture, Stack, Text } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function FloorPlan() {
  const zoom = signal(1.0)
  const baseZoom = signal(1.0)
  const x = signal(0.0)
  const baseX = signal(0.0)
  return (
    <Gesture
      onPinch={(e) => {
        zoom.set(baseZoom() * e.scale)
        if (e.phase === 'end') baseZoom.set(zoom())
      }}
      onPan={(e) => {
        x.set(baseX() + e.dx)
        if (e.phase === 'end') baseX.set(x())
      }}
    >
      <Stack translateX={x()} scale={zoom()} padding={4} background="muted">
        <Text>Drag or pinch</Text>
      </Stack>
    </Gesture>
  )
}

Put the transform on a child, as above, not on the Gesture itself. The values are measured in the region's own coordinates, so transforming the region feeds each update back into the next one (the compiler warns about this).

  • Web: a <div style="touch-action: none"> driven by pointer events. Pointer capture is taken only once a gesture passes the 10px slop, so a button inside the region still receives its click.

  • iOS: PyreonGesture { … }: a simultaneous DragGesture,MagnifyGesture and RotateGesture. Only the gestures you pass a handler for take part.

  • Android: PyreonGesture { … }: one pointerInput loop overcalculatePan, calculateZoom and calculateRotation, which consumes the pointer only past the platform touch slop (so a verticalScroll around it does not also scroll).

During a two-finger gesture the pan follows the centre point of the fingers on web and Android, and the first finger on iOS, because SwiftUI's DragGesturetracks a single touch.

Navigation to another route.

PropType
tostring (required)
externalboolean
// @check
import { Link, Text, init } from '@pyreon/primitives'

// Once, at app start: send internal links through your router.
init({ navigate: (to) => history.pushState(null, '', to) })

export function Nav() {
  return (
    <Link to="/about">
      <Text color="primary">About us</Text>
    </Link>
  )
}
  • Web: a real <a href>, so it works without JavaScript and can be opened in a new tab. This package does not depend on a router: callinit({ navigate }) once and ordinary left clicks go through yournavigate function instead. Clicks with a modifier key are left to the browser. external renders target="_blank" rel="noopener noreferrer".

  • iOS: PyreonLink("/about") { … }, a button that pushes the path onto the app's native router (@pyreon/native-router-swift, iOS 17+).

  • Android: PyreonLink("/about") { navigate -> Box(Modifier.clickable { navigate() }) { … } }from @pyreon/native-router-kotlin.

  • Not on native: external is ignored on iOS and Android, and the compiler warns. The link still pushes the URL onto the in-app router. To open a website, call useLinking().openUrl(url) from @pyreon/hooks instead.

Input

Field

A single-line text input.

PropTypeDefault
valuestring (or a signal read) (required)—
onChangeText(next: string) => void (required)—
kind'text' | 'number' | 'password' | 'email' | 'search' | 'tel' | 'url''text'
placeholderstring—
disabledbooleanfalse
onSubmit() => void—
// @check
import { Stack, Field, Text } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function EmailForm() {
  const email = signal('')
  const sent = signal(false)
  return (
    <Stack gap="sm">
      <Field
        kind="email"
        placeholder="you@example.com"
        value={email()}
        onChangeText={(t) => email.set(t)}
        onSubmit={() => sent.set(true)}
      />
      <Text>{sent() ? 'Sent!' : ''}</Text>
    </Stack>
  )
}
kindWeb typeiOSAndroid
texttextTextFieldTextField
passwordpasswordSecureFieldPasswordVisualTransformation()
emailemail.keyboardType(.emailAddress)KeyboardType.Email
numbernumber.numberPadKeyboardType.Number
teltel.phonePadKeyboardType.Phone
urlurl.URLKeyboardType.Uri
searchsearch.webSearchKeyboardType.Text

onSubmit runs on Enter on the web, .onSubmit on iOS, and the keyboard's Done action on Android (ImeAction.Done). Both value and onChangeTextare required on native. Without them the element can't be lowered and the compiler warns.

Toggle

An on/off switch.

PropType
valueboolean (or a signal read) (required)
onChange(next: boolean) => void (required)
disabledboolean
// @check
import { Inline, Toggle, Text } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function WifiSetting() {
  const on = signal(true)
  return (
    <Inline gap="sm" align="center">
      <Toggle value={on()} onChange={(v) => on.set(v)} accessibilityLabel="Wi-Fi" />
      <Text>Wi-Fi is {on() ? 'on' : 'off'}</Text>
    </Inline>
  )
}
  • Web: <input type="checkbox" role="switch">.

  • iOS: Toggle("", isOn: Binding(…)). Android: Switch(checked = …, onCheckedChange = …).

A dialog shown over the page.

PropType
openboolean (or a signal read) (required)
onClose() => void (required)
// @check
import { Stack, Button, Modal, Text } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function ConfirmDelete() {
  const open = signal(false)
  return (
    <Stack>
      <Button variant="danger" onPress={() => open.set(true)}>Delete</Button>
      <Modal open={open()} onClose={() => open.set(false)}>
        <Stack gap="md" padding={4}>
          <Text>Delete this item?</Text>
          <Button onPress={() => open.set(false)}>Cancel</Button>
        </Stack>
      </Modal>
    </Stack>
  )
}
  • Web: the browser's <dialog>, opened with showModal(). That gives you focus trapping, a backdrop and top-layer stacking for free. Pressing Escape or clicking the backdrop calls onClose; the dialog does not close itself, so your open signal stays accurate.

  • iOS: a .sheet(isPresented:) attached to an invisible anchor view. Swiping the sheet down calls onClose.

  • Android: if (open) { Dialog(onDismissRequest = onClose) { … } }.

Animation

Import both from @pyreon/primitives. The versions in @pyreon/runtime-domare web-only and the native compiler flags them.

Transition

Animates its children in and out as show changes.

PropTypeDefault
showboolean (or a signal read) (required)—
name'fade' | 'scale' | 'scaleIn' | 'slideUp' | 'slideDown' | 'slideLeft' | 'slideRight' (kebab-case such as 'slide-up' also works)'fade'
durationnumber (ms)300
easing'linear' | 'ease-in' | 'ease-out' | 'ease-in-out''ease-in-out'
enterDuration, leaveDurationnumber (ms)duration
enterEasing, leaveEasingeasingeasing
// @check
import { Stack, Button, Text, Transition } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function Toast() {
  const visible = signal(false)
  return (
    <Stack gap="sm">
      <Button onPress={() => visible.set(!visible())}>Toggle</Button>
      <Transition show={visible()} name="slideUp" enterDuration={150} leaveDuration={400}>
        <Text>Saved</Text>
      </Transition>
    </Stack>
  )
}
  • Web: a wrapper <div> whose opacity and transform use CSS transitions.

  • iOS: ZStack { if show { Group { … }.transition(…) } } with.animation(…, value: show). slideUp is .move(edge: .bottom) combined with a fade. Separate enter/leave settings use .asymmetric(insertion:removal:).

  • Android: AnimatedVisibility(visible = show, enter = …, exit = …) withtween specs.

  • On native, the durations must be literal numbers. Other values fall back to the default, and the compiler warns.

TransitionGroup

Animates its own height when the list inside it grows or shrinks. It takes only children.

// @check
import { For } from '@pyreon/core'
import { Stack, Button, Text, TransitionGroup } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function GrowingList() {
  const items = signal<string[]>([])
  return (
    <Stack gap="sm">
      <Button onPress={() => items.set([...items(), `Item ${items().length + 1}`])}>Add</Button>
      <TransitionGroup>
        <For each={items()} by={(i) => i}>
          {(i) => <Text>{i}</Text>}
        </For>
      </TransitionGroup>
    </Stack>
  )
}
  • Web: a wrapper whose height is measured with ResizeObserver and animated over 300ms.

  • iOS: VStack { … }.animation(.default, value: items.count).

  • Android: Column(Modifier.animateContentSize()).

WebView

Shows web content inside a native app: an inline HTML string or a page. This is how a native app can use web-only components (ECharts, code editors, rich tables). data is sent into the page and onMessage receives strings the page sends back.

PropType
htmlstring (takes precedence over src)
srcbundled asset name, or an http(s) URL
dataany JSON value, re-sent when it changes
onMessage(message: string) => void
domStorageboolean, default true; native only, literal value
growboolean, default false; literal value on native
// @check
import { WebView } from '@pyreon/primitives'
import { signal } from '@pyreon/reactivity'

export function ChartHost() {
  const points = signal([3, 1, 4, 1, 5])
  const clicked = signal('')
  return (
    <WebView
      src="chart.html"
      data={points()}
      onMessage={(m) => clicked.set(m)}
      data-testid="chart"
    />
  )
}
  • Web: an <iframe> (srcdoc for html), full width and height.

  • iOS: PyreonWebView(…) on WKWebView. Android: PyreonWebView(…)on Android's WebView. On both, data is JSON-encoded and pushed into the page without reloading it.

  • DOM storage: localStorage works in the hosted page by default. Android's WebView ships with it disabled, so the host turns it on; passdomStorage={false} to opt out (iOS uses a non-persistent data store, since WebKit cannot switch Web Storage off). Storage is per origin; every page loaded with inline html shares the app's file:///android_asset/ origin on Android, so give a page that needs isolated storage a src.

  • Sizing: without grow a web view is what an <iframe> with no CSS is, on every target: as wide as its parent and 150 high. With grow it takes the free space of its parent <Stack> (height) or <Inline> (width) after the siblings, and several growing views share it equally. Outside a stack it fills its parent. Inside a <Scroll> there is no free space to take, so it stays 150 high (the compiler warns and drops the prop). On the web, a root<Stack> only has free height when the page gives it one (height: 100vh), which a native screen always does.

    <Stack>
      <Text>Checkout</Text>
      <WebView src="https://shop.example/checkout" grow />
    </Stack>
  • Browser chrome: what a hosted page asks of the browser around it works on iOS and Android, not only on the web:

    page usesiOSAndroid
    <input type="file">the system chooserthe document picker; the camera for capture (needs the scaffold's FileProvider)
    alert / confirm / prompta native alerta native dialog
    navigator.geolocationWebKit's per-site prompta per-site prompt, then the runtime permission
    getUserMediaWebKit's per-site prompta per-site prompt, then the runtime permission
    window.open, target="_blank"opened in the system browseropened in the system browser

    Location, camera and microphone need the app to declare them: an Info.plist usage string (NSLocationWhenInUseUsageDescription, NSCameraUsageDescription,NSMicrophoneUsageDescription) and the AndroidManifest permission (ACCESS_FINE_LOCATION, CAMERA, RECORD_AUDIO). The create-multiplatformscaffold lists them commented out; uncomment the ones your pages use. An undeclared one is refused, and Android logs which entry to add. Onlyhttp(s), mailto, tel and sms links are handed to the system, so a hosted page cannot launch other apps through a custom scheme.

  • Inside the page, connectWebHost() from this package reads the data (window.__pyreonData, updated on each pyreondata event) and sends messages back. See the multiplatform guide for the full pattern.

Escape hatches

<Web>, <NativeIOS> and <NativeAndroid> render their children on one target only. Use them when one platform needs a different arrangement.

// @check
import { Stack, Text, Web, NativeIOS, NativeAndroid } from '@pyreon/primitives'

export function PlatformNote() {
  return (
    <Stack>
      <Web><Text>Running in a browser</Text></Web>
      <NativeIOS><Text>Running on iOS</Text></NativeIOS>
      <NativeAndroid><Text>Running on Android</Text></NativeAndroid>
    </Stack>
  )
}
  • Web: <Web> renders its children without a wrapper element; the other two render nothing.

  • iOS keeps only the <NativeIOS> branch, and Android only the<NativeAndroid> branch.

  • The children are still ordinary primitives. You cannot write raw Swift or Kotlin here. For that, useuseNativeModule.

When to use @pyreon/elements instead

@pyreon/primitives is deliberately small and uses fixed tokens. Choose@pyreon/elements when the app is web-only and needs responsive breakpoints, theme-aware CSS, pseudo-state styles or rocketstyle-style components. Those are not available on native.

See also

@pyreon/primitives