@swiftbrowser/react
v0.6.7
Published
SwiftUI's components for React: a react-reconciler host that emits SwiftBrowser render ops into the @swiftbrowser/web renderer, so a pure React/TypeScript web app gets the same iOS UI as a SwiftUI app in the browser.
Readme
@swiftbrowser/react
SwiftUI's components for React. A pure React/TypeScript web app writes its
screens with List, NavigationStack, Toggle, Picker and the rest, and
gets the same iOS UI a SwiftUI app gets in SwiftBrowser: the components emit
the render ops the Swift shim emits, and the
web renderer draws them with its iOS layout engine,
typography, animations, navigation bars, tab bars, sheets and alerts. No
Swift, no WebAssembly: the package is React components and a renderer host.
It works in both directions with ordinary React, and the two nest: a
SwiftUI tree can live inside a react-dom page, and ordinary React DOM can
live inside a SwiftUI element (see Mixing with ordinary React).
The package is experimental.
npm install @swiftbrowser/react @swiftbrowser/web react react-dom react-reconcilerreact-reconciler is a peer dependency, so the app picks the release that
matches its React: 0.31 for React 19.0, 0.32 for 19.1, 0.33 for 19.2, 0.34
for 19.3 (npm install [email protected] on React 19.2, say); the
package works with all four.
It is built for a bundler (Vite, or any that compiles TypeScript in
dependencies): the renderer it drives comes from @swiftbrowser/web's
TypeScript sources, as does the stylesheet the page imports. The stylesheet
is the renderer's alone (the device's tokens, the frame, the elements): it
leaves the page's own body, classes and box model as they are, and an
island is as wide and as tall as the page's CSS makes it. With Vite, list
@swiftbrowser/web in optimizeDeps.exclude so the renderer's bitmap
worker resolves from its sources.
Getting started
A whole page
The simplest app is a page whose root is a SwiftUI tree. The page provides
the renderer's stylesheet and a root element, and mount draws the tree in
it. In the device frame (the markup of index.html here, with the iPhone
bezel, status bar and the theme controls) the root is the #screen element;
on a phone the renderer can also draw full screen.
import '@swiftbrowser/web/src/styles.css';
import { useState } from 'react';
import { mount, Label, List, NavigationLink, NavigationStack, Section, Text, Toggle } from '@swiftbrowser/react';
function Library() {
const [downloads, setDownloads] = useState(true);
return (
<List navigationTitle="Library">
<Section header="Reading">
<NavigationLink destination={<Text>Detail</Text>}>
<Text>The Swift Programming Language</Text>
</NavigationLink>
</Section>
<Section header="Settings">
<Toggle isOn={[downloads, setDownloads]}>
<Label title="Automatic Downloads" systemImage="arrow.down.circle" />
</Toggle>
</Section>
</List>
);
}
mount(document.getElementById('screen')!, <NavigationStack><Library /></NavigationStack>);mount returns the root and the renderer; setEnvironment({ colorScheme,
dynamicTypeSize }) on it switches dark mode and the Dynamic Type size, as
the demo page's controls do.
Inside an existing React app
In a react-dom app, SwiftUIView is a normal component that draws its
children with the renderer in a box of the page's own layout, with the
device's design tokens and none of its frame:
import '@swiftbrowser/web/src/styles.css';
import { SwiftUIView, List, Section, Toggle } from '@swiftbrowser/react';
function Settings({ dark, setDark }) {
return (
<SwiftUIView fit="content" colorScheme={dark ? 'dark' : 'light'} className="settings">
<List>
<Section header="Appearance">
<Toggle title="Dark Mode" isOn={[dark, setDark]} />
</Section>
</List>
</SwiftUIView>
);
}fit="content" makes the island as tall as its content, like a block in
the page's flow; fit="fill" (the default) draws in the box the page sizes
with CSS, which is what navigation stacks and tab views want, since they fill
their screen. colorScheme and dynamicTypeSize are props. The island is
a React root of its own: the page's React state reaches it through props as
usual, and its contexts through contexts={[…]} (see
Contexts across roots).
There is no status bar in a page, so safeArea stands in for one. A
fill island keeps 10 points at the top, the way SwiftUI keeps a window's
bars off its edge. Navigation bars sit below that inset and their
background reaches up into it, and lists start below it. A content
island has no insets. Pass a number for the top inset (safeArea={0} puts
the bars flush with the edge) or an object such as
safeArea={{ top: 24, bottom: 34 }}.
Try the demos
npm install
cd packages/react
npm run dev # http://localhost:5174/ — Examples/Fidelity, in React
# http://localhost:5174/island.html — islands in a react-dom page
# http://localhost:5174/wide.html — a regular-width app: split view, table, inspector
npm run test:unit # the React Fidelity tabs emit the same tree as the Swift app?screen=<name> on the first page opens one of the Fidelity screens alone
(list, detail, form, controls, typography, shapes, layout,
tabs, sheet, alert, search, post, settings, reorder), like the
Swift app's -screen argument; extras, more and web show the
components beyond it.
Writing views
Components
The components are named as in SwiftUI and take the same arguments as
props: <VStack alignment="leading" spacing={8}>, <Text font="headline">,
<Image systemName="heart.fill" />, <Button title="Save" action={save} />,
<Picker label="Plan" selection={[plan, setPlan]} options={plans} />,
<Section header="Reading" footer="…">. Children are the content closure.
ForEach takes data and a render function, with id, onDelete and
onMove as in SwiftUI; Group applies its modifiers to each child.
Text is plain strings and numbers inside <Text>; nested <Text>s and
inline <Image>s make a rich text (Text + Text), and italic,
underline, strikethrough and tracking are its own props.
Modifiers as props
Every component takes SwiftUI's modifiers as props, and applies them in the order they are written, innermost first, so a chain reads the same:
<Image
systemName="swift"
font={system(17, 'semibold')}
foregroundStyle="white"
frame={{ width: 32, height: 32 }}
background={{ fill: 'orange', in: <RoundedRectangle cornerRadius={8} /> }}
/>is Image(systemName: "swift").font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white).frame(width: 32, height: 32).background(.orange, in:
RoundedRectangle(cornerRadius: 8)). Each modifier wraps the view in the same
styled element the Swift shim emits, so the renderer lays it out
identically. A modifier that must apply twice goes in modifiers={[…]}, an
ordered list. A layered style is an array: foregroundStyle={['white',
'indigo']} is .foregroundStyle(.white, .indigo), whose second style a
symbolRenderingMode="palette" symbol draws its disc in. The environment
modifiers (buttonStyle, controlSize, pickerStyle, labelsHidden,
symbolRenderingMode) are React contexts the views below read, and
navigationTitle, toolbar, toolbarRole, searchable, sheet, alert,
presentationDetents and preferredColorScheme attach to the enclosing
screen, sheet or page, as the SwiftUI preferences do. Effects (blur,
grayscale, saturation, brightness, contrast, hueRotation,
colorInvert, colorMultiply, blendMode, mask, rotation3DEffect),
layout hints (aspectRatio, scaledToFit, position, zIndex,
alignmentGuide, ignoresSafeArea, allowsHitTesting, lineLimit ranges)
and the lifecycle (onAppear, task with an AbortSignal for
cancellation, onChangeOf) are modifiers too, as are draggable and
dropDestination.
Values are plain data: colors by name ("red", "secondary",
"secondarySystemBackground") or through Color (Color.opacity('black',
0.2), Color.gradient('teal'), Color.hex('#336699')); fonts by text
style ("title2") or { textStyle: 'title2', weight: 'bold' } and
system(size, weight, design); frames as { width, height, maxWidth:
Infinity, alignment }; padding as true, a number, ['vertical', 6] or
insets; gradients through LinearGradient, RadialGradient and
AngularGradient.
Bindings
A binding is a useState pair, so isOn={[downloads, setDownloads]} reads
like isOn: $downloads. Everything that takes one (Toggle, Picker,
Slider, Stepper, TextField, TextEditor, DatePicker, TabView's
selection, List's selection and editing, NavigationStack's path,
the sheet and alert modifiers' isPresented) also takes a plain value
with an onChange handler. The setter can be anything with that signature.
When the renderer sends an event (a tap, a switch, a selection), the handler's state updates render synchronously and the renderer gets the next pass inside the event, the way the Swift core answers one.
Navigation and presentation
NavigationStack holds the pushed values (or takes a path binding).
NavigationLink pushes a value, which the enclosing screen's
navigationDestination builds a screen for, or a destination view
directly. A pushed screen's title takes the display mode of the screen below
it unless it sets its own (large below a large title), as on iOS. TabView
holds Tabs, each with a systemImage or an asset catalog image (a custom
symbol) for its bar item. .sheet and .alert are modifiers whose
content and actions render as root-level elements while presented;
presentationDetents inside a sheet sets its detents. useDismiss() inside
a pushed screen or a sheet pops or dismisses it.
Toolbars
ToolbarItem's placement takes SwiftUI's names. secondaryAction items
collapse into the bar's More menu (an ellipsis at the trailing end), as on
an iPhone; with toolbarRole="editor" on the screen, a regular width shows
them in the bar before the trailing items, and only those that do not fit
go into More. Items float in Liquid Glass capsules, as iOS 26's do:
consecutive items of one placement share a capsule (in the navigation bar
too, so two trailing ToolbarItems are one capsule, not two circles), and
a ToolbarSpacer of that placement ends it. In the bar the capsules sit
12 apart; at the foot of the screen a flexible spacer (the default)
pushes the groups apart and a fixed one keeps them centered, 8 apart. A
swapped item ({playing ? <ToolbarItem>…stop…</ToolbarItem> :
<ToolbarItem>…play…</ToolbarItem>}) changes on a capsule that stays.
<ToolbarItem placement="bottomBar">{undoButton}{redoButton}</ToolbarItem>
<ToolbarSpacer sizing="fixed" placement="bottomBar" />
<ToolbarItem placement="bottomBar">{toolButtons}</ToolbarItem><List
navigationTitle="Notes"
toolbarRole="editor"
toolbar={
<>
<ToolbarItem placement="primaryAction">
<Button action={compose}>
<Image systemName="square.and.pencil" />
</Button>
</ToolbarItem>
<ToolbarItem placement="secondaryAction">
<Button title="Export" action={exportNotes} />
</ToolbarItem>
<ToolbarItem placement="secondaryAction">
<Button title="Archive" action={archive} />
</ToolbarItem>
</>
}
>
{/* … */}
</List>Lists
List and Form hold Sections and rows. selection={[…]} makes rows with
a tag (or a NavigationLink value) selectable, one value or a Set;
editing is the edit mode, which EditButton flips. ForEach's onDelete
makes its rows deletable (a swipe, or edit mode's minus) and onMove makes
them reorder with a grip, with moveItems to apply the move to an array.
swipeActions, contextMenu and listRowBackground are modifiers on a row.
Size classes
useHorizontalSizeClass() is compact below 700 points of width (a phone)
and regular from it (a tablet, a desktop window); mount and
SwiftUIView follow the root's width as it changes. The views that adapt do
it as on iOS: NavigationSplitView is two columns when regular (the
sidebar's toggle button hides and shows the sidebar) and one stack when
compact, which pushes the detail while a List(selection:) in the sidebar
has a selection and clears it on back (lists without a selection, and
anything a sheet, popover or inspector presents, have no say); .inspector is a trailing panel when
regular and a sheet when compact; Table shows every column under sortable
titles when regular and the first column when compact.
<NavigationSplitView
sidebar={<List selection={[team, setTeam]}>{/* rows with a tag */}</List>}
detail={<Table data={people} columns={columns} sortOrder={[order, setOrder]} />}
/>Scrolling
List, LazyVStack and LazyHStack build a long ForEach a batch of
rows at a time as they scroll into view. ScrollViewReader hands a proxy
whose scrollTo(id, anchor) scrolls to the view marked scrollID={id},
building lazy rows up to it first. refreshable (on a List or
ScrollView, or above one) runs an async action on a pull, with the
spinner while it runs. scrollTargetBehavior="paging" or "viewAligned" on
a ScrollView snaps, with scrollTargetLayout on the stack inside.
Name a coordinate space with coordinateSpace="page" on the ScrollView
(or any view), and a GeometryReader inside reads its frame there with
proxy.frame({ named: 'page' }), updated as the page scrolls.
scrollTransition draws effects as a view leaves the visible area of its
scroll view and comes back. The closure gets the effect to build on and the
phase (topLeading, identity or bottomTrailing, with value -1, 0 or
1). By default the effect follows the scrolling, drawn partway while the
view is partly out; configuration: 'animated' switches phases with an
animation instead. containerRelativeFrame sizes a view against the scroll
view's visible area, so a carousel of cards is:
<ScrollView axes="horizontal" contentMargins={['horizontal', 20]} scrollEdgeEffectStyle="hard">
<HStack spacing={12}>
{cards.map((card) => (
<CardView
key={card.id}
card={card}
containerRelativeFrame={{ axes: 'horizontal', count: 5, span: 2, spacing: 12 }}
scrollTransition={(effect, phase) => effect.scaleEffect(phase.isIdentity ? 1 : 0.85).opacity(phase.isIdentity ? 1 : 0.4)}
/>
))}
</HStack>
</ScrollView>contentMargins pads the content inside the scroll views below (it scrolls
under the margin). scrollEdgeEffectStyle (hard, soft, automatic, or
["hard", "top"] for one edge) and scrollEdgeEffectHidden set the iOS 26
edge effect where a list or scroll view runs under a bar. All three apply to
the scroll view they are written on and pass down to the ones inside, as
SwiftUI's environment does. LazyHGrid lays its cells into rows column by
column, for a horizontal ScrollView.
Drawing and media
Shape takes a path function of the rectangle it is laid out in and
draws a Path (move, addLine, addCurve, addArc, addRoundedRect…);
UnevenRoundedRectangle and ContainerRelativeShape are built on it.
Canvas hands its renderer a GraphicsContext (fill, stroke, text, images,
transforms, opacity, clips, filters, gradients in its own coordinates), and
TimelineView re-renders on a schedule, so a clock is a TimelineView
around a Canvas. Views passed in the symbols prop ({ star: <Image
systemName="star.fill" /> }) come back from resolveSymbol and draw with
drawSymbol at a point, under the context's transform and opacity; they are
real views laid over the canvas, so they draw above its other commands. useAVPlayer(url) is an AVPlayer (play, pause, seek,
mute, volume, rate) that VideoPlayer shows in the page's <video>; the
position is read or observed without re-rendering.
Mouse, keyboard and effects
A web app may have a mouse and a keyboard, so the modifiers SwiftUI keeps
for iPad and Mac work here: onHover (a mouse entering and leaving the
view's frame), help (a tooltip), pointerStyle (link, text,
grabIdle… or any CSS cursor), keyboardShortcut on a Button (⌘ is
Ctrl off Apple platforms) and onKeyPress (keys pressed while no text field
has the focus). symbolEffect draws SF Symbols' effects (bounce, pulse,
variableColor, rotate, wiggle, breathe, scale, appear), running
while active or once per change of a value. visualEffect={(effect, proxy) =>
…} draws effects (offset, scale, rotation, opacity, blur, color
adjustments) from the view's geometry without changing its layout; the
proxy's frame('global') and bounds('scrollView') stay current as the page
scrolls, so a parallax or a fade by position is one closure. matchedGeometryEffect with a
namespace from useNamespace() moves a view into the frame of the one it
replaces in an animated change. contentTransition="numericText" rolls a
text's old value out and the new one in when it changes in an animated pass,
upward or downward as the number grows or shrinks ({ numericText: {
countsDown } } fixes the direction); the whole text rolls, not each digit.
opacity and interpolate crossfade. sensoryFeedback vibrates where the
device can.
Animation
withAnimation(animation, () => …) animates the pass a state change makes,
and the animation modifier a view's changes when a value changes.
PhaseAnimator moves its content through phases, each change animated;
KeyframeAnimator plays tracks of keyframes (linear, cubic, spring,
move) and renders its content with the values each frame.
Charts
Chart takes BarMark, LineMark, AreaMark, PointMark and RuleMark
with their values in x and y: categories, numbers or dates. The
component builds the scales and ticks the Swift Charts module builds
(bands, nice numeric domains that include zero, calendar strides), groups
lines and areas by series, colors foregroundStyleBy series from the chart
palette with a legend, and places annotations.
Mixing with ordinary React
Both directions work, and they nest: a react-dom page can hold a SwiftUI
island, and the island can hold ordinary React DOM inside one of its rows.
The island page of the demo (island.html) does exactly that.
A SwiftUI tree inside a react-dom app
SwiftUIView, shown in Getting started:
a component whose children are a SwiftUI tree, drawn in a box of the page.
React DOM inside a SwiftUI element
Host puts ordinary React DOM, with its own components, stylesheets and
event handlers, inside a SwiftUI tree. Its children render with react-dom
in a host element the layout engine sizes, so the DOM sits inside list
rows, stacks and screens like any other view:
function Composer() {
const [notes, setNotes] = useState('');
return (
<List>
<Section header="Appearance">
<LabeledContent label="Notes" value={`${notes.length} characters`} />
</Section>
<Section header="Composer" footer="A textarea and a counter, rendered by react-dom inside the list row.">
<Host className="composer">
<textarea value={notes} placeholder="Write something…" onChange={(e) => setNotes(e.target.value)} />
<div className="count">{280 - notes.length}</div>
</Host>
</Section>
<Section header="Map">
<Host sizing="fill" frame={{ height: 160 }}>
<MapView />
</Host>
</Section>
</List>
);
}Typing in the textarea updates notes in the React component above, the
SwiftUI row shows the count, and the row grows with the text.
- Sizing.
sizing="content"(the default) measures the DOM like text: as wide as the content wants, up to the width the parent proposes, and as tall as it is at that width. Whenever the content changes size (more lines, an image that loaded, a state change), the tree is laid out again around it.sizing="fill"takes the proposal, so aframe(or the parent's size) decides the box and the content fills it;height: 100%in the content reaches the box. - Styling. The content inherits the device's font and text color, and
the iOS tokens are CSS custom properties it can use:
var(--sb-color-secondary),var(--sb-color-accent),var(--sb-color-separator), the Dynamic Type sizes, and so on; they follow dark mode.classNameandstyleland on the wrapper the children render in. - Events. The DOM handles its own events. Pointer downs inside a host never start the renderer's gestures (no swipe, no drag), so text selection, scrolling inside the host and native controls work; the row around it still swipes when the pan starts outside the host.
- Hooks. The content can use this package's hooks:
useDismiss()from a sheet or a pushed screen,useNavigation()to push,useColorScheme().
A host is a React root of its own, so it needs the react-dom peer
dependency, and React context does not cross into it by itself; see below.
It survives StrictMode's simulated unmount and remount, as does
SwiftUIView (island.html?strict in the demos runs under it).
Contexts across roots
SwiftUIView and Host each start a React root, and React context stops at
a root boundary. This package's own contexts (the environment, navigation,
dismiss, the view environment) are bridged automatically. For the page's
own contexts, name them on the component and they are read on the outer
side and provided again inside:
<SwiftUIView contexts={[ThemeContext, RouterContext]}>…</SwiftUIView>
<Host contexts={[ThemeContext]}>…</Host>The list must be the same on every render of that component (each entry is read with a hook). Props, callbacks and external stores need no bridging.
How it works
src/host/: a react-reconciler host whose instances are protocol elements. After each React commit,HostTree.flushresolves two virtual kinds (a preference merges props into the enclosingnavscreenorsheet; a hoist moves children to the enclosing screen or to the root, as.toolbar,.sheetand.alertdo), diffs the result against the last pass and emits one render pass: creates, updates, inserts (parents before children), removes,commit. Events from the renderer reach the element's handlers and flush synchronously.createSwiftUIRoot(sink)renders a tree into any ops sink;mount(screen, element)pairs a root with aRendererfrom@swiftbrowser/web/src/renderer.withAnimation(() => setCount(1))stamps the pass'scommitwith the animation, aswithAnimation { }does.src/modifiers.tsxapplies the modifier props;src/components/holds the components;src/components/host.tsxandsrc/island.tsxthe two mixing components, over the renderer'shostelement kind and its island layout mode (docs/ops-protocol.md).test/fidelity.test.tsxrendersExamples/Fidelity's tab view through the host and compares the element tree withExamples/Fidelity/__snapshots__/fidelity.jsonl, the ops the Swift app emits for the same screens;test/extras.test.tsxcovers the components beyond the Swift app.
What is covered
- Layout: stacks,
LazyVStack,LazyHStack,Spacer,Divider,ScrollView(withcontentMarginsand scroll edge effects),ScrollViewReader,Group,ForEach(withonDeleteandonMove),Grid,LazyVGrid,LazyHGrid,ViewThatFits,GeometryReader(its size, its frame on screen or in acoordinateSpacenamed on an ancestor, the visible bounds of its scroll view, and its safe-area insets), customLayout(withlayoutValues, priorities, view spacing, explicit alignment guides and a cache),containerRelativeFrame,safeAreaInset. - Content:
Text(withText + Textruns, inline images, italic, underline, strikethrough, tracking),Image(symbols, catalog images with theassetsoption, remote images andAsyncImage),Labeland its styles,LabeledContent,Link,Coloras a view, shapes (built-in, custom paths,UnevenRoundedRectangle,ContainerRelativeShape) with fills, strokes and gradients,Canvas,TimelineView,VideoPlayer,Gauge,ProgressView, Swift Charts. - Controls:
Button(styles, roles, sizes),Toggle,Picker,Slider,Stepper,TextFieldandSecureField(focus bindings, submit labels),TextEditor,DatePicker,ColorPicker,ShareLink,Menu,EditButton. - Containers:
List(selection, edit mode, swipe actions, deletion, reordering, pull to refresh),Form,Section,Table,GroupBox,DisclosureGroup,ControlGroup,ContentUnavailableView. - Navigation and presentation:
NavigationStack(value and destination links, apathbinding, nested stacks flattened),NavigationSplitView,TabView/Tab, toolbars, search fields, sheets with detents, popovers (as sheets), alerts and dialogs, inspectors. - Interaction:
onTapGesture,dragGesture,onLongPressGesture,contextMenu,draggableanddropDestination(in the app, and text or files from outside the page),onHover,help,pointerStyle,keyboardShortcut,onKeyPress,sensoryFeedback. - Animation and effects:
withAnimation,animation, transitions,PhaseAnimator,KeyframeAnimator,matchedGeometryEffect,symbolEffect,contentTransition,visualEffect,scrollTransition,redactedplaceholders,textCase. - Mixing:
HostandSwiftUIView.
Not yet
contentTransition="numericText"rolls the whole text rather than only the digits that changed, andCanvassymbols always draw above the canvas's other commands.scrollTransitiontakes no thresholds: the effect starts as soon as any of the view is out of the visible area and is complete when all of it is.containerRelativeFramemeasures against the scroll view's whole visible area, content margins included, and itslengthclosure is read as a linear function of the container's length.- The Swift-only bridges (SwiftData, URLSession, notifications, Core ML, StoreKit) have no React counterpart, by design: a web app uses the platform's own.
- The ops protocol is the contract with the renderer and is versioned with it:
this package imports the renderer's sources from
@swiftbrowser/web/src/*.
