| name | expo-ui-doc-screenshots |
|---|---|
| description | Add hero screenshots to Expo UI SwiftUI / Jetpack Compose component docs. Renders the example in bare-expo, captures both light and dark mode, embeds via the theme-aware ComponentDiagram with a fixed 3:2 or 9:16 canvas (zero CLS). |
User wants to add a hero image to an @expo/ui component doc page so users see the component as soon as the page loads. Each component gets two screenshots (light and dark) that swap automatically based on the docs site's theme.
- Doc MDX (canonical):
docs/pages/versions/unversioned/sdk/ui/swift-ui/<component>.mdx - Doc MDX (versioned):
docs/pages/versions/v<N>.0.0/sdk/ui/swift-ui/<component>.mdx— also tracked in git, edit in lockstep - Doc MDX (generated):
docs/pages/versions/latest/sdk/ui/swift-ui/<component>.mdx— gitignored, regenerated fromunversioned/. Don't edit - Image destinations:
docs/public/static/images/expo-ui/<component>/ios-{light,dark}.webp - Render harness:
apps/bare-expo/App.tsx—TestComponentis added temporarily, removed after the session - ComponentDiagram:
docs/ui/components/Diagram/ComponentDiagram.tsx
Two canvases. Pick the closest match:
| Mode | Aspect | Source | Displayed | Use for |
|---|---|---|---|---|
landscape |
3:2 | 1080×720 |
540×360 |
~90% — form rows, display content (Text, Label, Gauge), inputs, buttons, layouts (HStack, ZStack, VStack with content), inline overlays (Popover, Menu, ConfirmationDialog) |
portrait |
9:16 | 540×960 |
202×360 |
Full-phone — BottomSheet, ContextMenu, TabView |
The standardization is the point. Don't crop tight to a component's natural shape — pad/space the render until it fills the chosen canvas. ComponentDiagram reserves the exact final box pre-load via aspect-ratio + fixed width capped by max-w-full — zero CLS, proportional shrink on narrow viewports.
Padding around the component must be equal on opposing sides. Top padding = bottom padding, and left padding = right padding. Off-center crops read as "the component fell to one side of its frame" and look broken — visually, your eye snaps to the asymmetry instead of the component. Before saving any cropped image, verify by reading it back: imagine a center cross-hair on the canvas; the component should sit on that cross-hair, with empty space distributed symmetrically. If it doesn't, re-measure (sample the source PNG to find content bounds), recompute crop_top so (crop_top + crop_bottom) / 2 == content_midpoint_y, and re-save. Crop coordinates are always cheaper to iterate on than re-rendering.
MDX placement: ComponentDiagram goes BEFORE the intro paragraph, immediately after the imports. This pins the hero to a fixed offset from the page title, identical across all component pages — the eye finds the diagram in the same place when navigating between docs. The variable-length intro becomes the thing that shifts (text, faster to scan).
import APISection from '~/components/plugins/APISection';
import { APIInstallSection } from '~/components/plugins/InstallSection';
import { ComponentDiagram } from '~/ui/components/Diagram';
<ComponentDiagram
mode="landscape"
source="/static/images/expo-ui/<component>/ios-light.webp"
darkSource="/static/images/expo-ui/<component>/ios-dark.webp"
alt="<short description of what's shown>"
/>
Expo UI <Component> matches the official SwiftUI [<Component> API](https://...).
## InstallationThere are exactly two ways to compose TestComponent for a hero shot. Pick by component type.
For self-contained components rendered free-floating on a system background: Button, Gauge, ProgressView, Image, Spacer, Text, Label, Link, Divider, Group, HStack, VStack, ZStack, Overlay, ControlGroup, ScrollView, Menu, Popover (when triggered).
const TestComponent = () => {
const colorScheme = useColorScheme();
return (
<View style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colorScheme === 'dark' ? '#000000' : '#FFFFFF',
}}>
<Host matchContents={{ vertical: true }} style={{ width: 320 }}>
<VStack spacing={20}>
{/* component content */}
</VStack>
</Host>
</View>
);
};Crop: (0, 909, 1206, 1713) → resize to 1080×720. Always. The screen midpoint is y=1311 (= 2622/2), and a 3:2 box of height 804 centered there is exactly [909, 1713]. No measurement needed; justifyContent: 'center' puts content at screen-mid, the crop captures it symmetrically.
backgroundColor matches iOS systemBackground: #FFFFFF light, #000000 dark. Use useColorScheme() from react-native.
matchContents={{ vertical: true }} is required to size the Host to its content (not full screen). The object form is non-negotiable — matchContents={true} won't work.
For SwiftUI list rows that need iOS list styling (rounded card, separators, chevron disclosure): Toggle, DisclosureGroup, DatePicker, Picker, TextField, SecureField, ColorPicker, Slider (with label), and the layout components Form, Section, List.
The wrapping View's paddingTop does NOT push SwiftUI Form content down — Form anchors to its own safe-area inset. Use empty Section blocks inside the Form as SwiftUI-side spacers, then crop above them so they fall out of frame:
const TestComponent = () => {
const [wifi, setWifi] = useState(true);
// ... state for each row
return (
<Host style={{ flex: 1 }}>
<Form>
<Section>
<Spacer />
</Section>
<Section title="Connectivity">
<Toggle label="Wi-Fi" isOn={wifi} onIsOnChange={setWifi} />
<Toggle label="Bluetooth" isOn={bluetooth} onIsOnChange={setBluetooth} />
<Toggle label="Airplane Mode" isOn={airplane} onIsOnChange={setAirplane} />
</Section>
</Form>
</Host>
);
};Crop: (0, 460, 1206, 1264) → resize to 1080×720. y=460 lands just below the spacer card so it's invisible in the final image, leaving ~90 px of clean breathing room above the real Section header.
Three rows is the canonical row count for this canvas — verified across Toggle, DisclosureGroup, DatePicker, Picker, ColorPicker, TextField, SecureField. Two rows leaves visual emptiness (form pinned to upper third); four rows clips the bottom. Always reach for three.
Exception — Section/Form with both header + footer: the visible content (header text + card + footer text) is taller than 3 rows. Use two spacer Sections to push everything down further, then sample to find content bounds and pick a custom crop centered on the form midpoint. Example: for Section's hero with 2 rows + footer, two spacers + crop (0, 735, 1206, 1539).
Only BottomSheet, ContextMenu, TabView, and vstack (when demonstrating full-phone vertical content). These need the whole iPhone screen as the source.
const TestComponent = () => (
<Host style={{ flex: 1 }}>
<VStack>
<BottomSheet isPresented={true} onIsPresentedChange={() => {}}>
{/* content */}
</BottomSheet>
</VStack>
</Host>
);Crop: scale the 1206×2622 screenshot to 540 wide preserving aspect, then center-crop to 540×960:
src = Image.open(f'/tmp/{c}-{theme}.png')
w, h = src.size
nh = int(h * 540 / w) # 1175
scaled = src.resize((540, nh), Image.LANCZOS)
y0 = (nh - 960) // 2 # ~107
cropped = scaled.crop((0, y0, 540, y0 + 960))This trims ~107 px from top and bottom of the scaled phone, keeping the natural backdrop and the sheet/menu/preview in frame. Do NOT use fit_into with grey padding — synthetic letterbox always looks worse than a real screenshot, even if the real one trims slightly.
- Simulator: iPhone 17 Pro on iOS 26.2 (UDID
5611185F-C94B-496C-943B-DC8D71120D00). Other 17 Pro sims do not have the BareExpo build - Metro is usually already running on port 8081 (check
lsof -nP -i:8081 | grep node). If not,cd apps/bare-expo && pnpm start - Launch app:
xcrun simctl launch <UDID> dev.expo.Payments - For interactive components, also start an
agent-devicesession:agent-device boot --platform ios && agent-device open --platform ios dev.expo.Payments
For static components (no interactive trigger needed):
sleep 5 # let hot reload settle
xcrun simctl ui <UDID> appearance light && sleep 2 \
&& xcrun simctl io <UDID> screenshot /tmp/<comp>-light.png \
&& xcrun simctl ui <UDID> appearance dark && sleep 3 \
&& xcrun simctl io <UDID> screenshot /tmp/<comp>-dark.png \
&& xcrun simctl ui <UDID> appearance lightAlways set appearance light before the first capture. If a previous run left the sim in dark mode, your "light" screenshot will silently come out dark.
from PIL import Image
crop_box = (0, 460, 1206, 1264) # form-row
# crop_box = (0, 909, 1206, 1713) # centered
for theme in ('light', 'dark'):
Image.open(f'/tmp/{c}-{theme}.png').crop(crop_box) \
.resize((1080, 720), Image.LANCZOS) \
.save(f'docs/public/static/images/expo-ui/{c}/ios-{theme}.webp', 'WEBP', quality=90, method=6)Run the script from the repo root. PIL → WebP at quality 90 hits the right size/quality knee.
for f in docs/pages/versions/unversioned/sdk/ui/swift-ui/<comp>.mdx \
docs/pages/versions/v<N>.0.0/sdk/ui/swift-ui/<comp>.mdx; do
perl -i -0pe 's{<ComponentDiagram\n(?! mode=)}{<ComponentDiagram\n mode="landscape"\n}' "$f"
doneThe negative lookahead (?! mode=) makes the substitution idempotent — running it twice doesn't double-insert.
When a component's content position is non-obvious (custom Form layouts, popovers, components mounted inside containers), sample the source PNG to find content bounds:
from PIL import Image
img = Image.open('/tmp/<comp>-light.png')
# Sample column at x=300 — lands inside form-row text area, white card vs grey bg is binary
col = [img.getpixel((300, y)) for y in range(img.size[1])]
runs = []; in_run=False; start=0
for y, p in enumerate(col):
is_white = p[0] >= 250
if is_white and not in_run: start=y; in_run=True
elif not is_white and in_run:
if y-1-start>20: runs.append((start, y-1, y-1-start))
in_run=False
print(runs)For the form-row recipe with one spacer, you should see exactly two white runs (the spacer card and the form card). The form's vertical span is between the end of the spacer and the bottom of the last form card. Compute the form midpoint, then pick a 3:2 crop centered on it (with crop_top ≥ end of spacer + a few pixels).
For centered components, no sampling needed — content is at screen midpoint y=1311 by construction.
These need a tap (or long-press) before the screenshot, otherwise the menu/sheet/preview isn't visible.
agent-device click 'text=Show details' # by accessibility label (preferred)
agent-device click 201 437 # by point coords (x y, points not pixels)
agent-device click 201 437 --hold-ms 1000 # long-press (for ContextMenu)iPhone 17 Pro is 402×874 points (= screenshot pixels / 3). Screen center: 201, 437. Selectors are usually fine; fall back to coordinates for components where the trigger doesn't have a stable accessibility label.
Long-press is --hold-ms — it's how you trigger ContextMenu's preview-with-menu interaction. Don't fake a static composition; use the real gesture.
The opened sheet/menu/popover keeps its light styling even after the appearance changes. SwiftUI's transient popovers (Menu, ContextMenu, Picker dropdowns, ConfirmationDialog) capture trait collection at presentation time and won't restyle in place.
The reliable dark capture flow: terminate, set appearance, relaunch (wait long enough for the splash + JS bundle to settle — 8–10 seconds), tap again:
# light
agent-device click 201 437 --hold-ms 1000
sleep 2
xcrun simctl io <UDID> screenshot /tmp/<c>-light.png
# dark — full relaunch is required
xcrun simctl ui <UDID> appearance dark
xcrun simctl terminate <UDID> dev.expo.Payments
sleep 1
xcrun simctl launch <UDID> dev.expo.Payments
sleep 10 # critical — short waits cause the tap to land on splash
agent-device click 201 437 --hold-ms 1000
sleep 2
xcrun simctl io <UDID> screenshot /tmp/<c>-dark.png
xcrun simctl ui <UDID> appearance lightFor overlays controlled by JS state (<BottomSheet isPresented={true}> rendered initially open), the dismiss-then-switch dance works without relaunch — SwiftUI restyles the modal on trait change.
Where possible, render with isPresented={true} so the overlay is open at first paint — saves a tap. (Note: Fast Refresh only uses initial state on first mount; if you reopen + dismiss manually, refresh won't reset the state.) Works for BottomSheet, ConfirmationDialog. Doesn't work for Menu, ContextMenu (need real trigger).
Popovers sit on a dimmed full-screen backdrop, so wide crops will look grey-padded regardless of where you crop. Crop tight to the bubble, not symmetrically to the screen.
For Menu (popover ~y=860–1330 in source): (0, 693, 1206, 1497) → 1080×720. The popover dominates the frame; the bordered card chrome of ComponentDiagram becomes the visual containment, not the iOS backdrop.
For ConfirmationDialog (smaller bubble): (138, 704, 1038, 1304) → 900×600 → resize to 1080×720. The bubble fills the frame with minimal grey context.
For ContextMenu (preview-with-menu, full-phone portrait): use Recipe C scaling.
// Toggle: 3 rows
<Section title="Connectivity">
<Toggle label="Wi-Fi" isOn={wifi} onIsOnChange={setWifi} />
<Toggle label="Bluetooth" isOn={bluetooth} onIsOnChange={setBluetooth} />
<Toggle label="Airplane Mode" isOn={airplane} onIsOnChange={setAirplane} />
</Section>
// DatePicker: 3 rows (rows are taller, may need slight crop adjust)
<Section title="Reminder">
<DatePicker title="Start" selection={start} onDateSelected={setStart} displayedComponents="date" />
<DatePicker title="End" selection={end} onDateSelected={setEnd} displayedComponents="date" />
<DatePicker title="Time" selection={time} onDateSelected={setTime} displayedComponents="hourAndMinute" />
</Section>
// Custom crop for 3 datepickers: (0, 506, 1206, 1310)
// Picker: 3 rows, menu style
<Section title="Preferences">
<Picker modifiers={[pickerStyle('menu')]} label="Fruit" selection={fruit} onSelectionChange={setFruit}>
{options.map(o => <Text key={o} modifiers={[tag(o)]}>{o}</Text>)}
</Picker>
{/* ... */}
</Section>// Button: glass styles (iOS 26)
<VStack spacing={16}>
<Button label="Continue" modifiers={[buttonStyle('glassProminent')]} onPress={() => {}} />
<Button label="Cancel" modifiers={[buttonStyle('glass')]} onPress={() => {}} />
<Button label="Skip for now" modifiers={[buttonStyle('plain')]} onPress={() => {}} />
</VStack>
// Gauge: three circular gauges with stoplight tints
<HStack spacing={32}>
<VStack spacing={6}>
<Gauge value={0.4} modifiers={[gaugeStyle('circular'), tint('#007AFF')]} currentValueLabel={<Text>40%</Text>} />
<Text>CPU</Text>
</VStack>
{/* ... */}
</HStack>
// Text: typography ladder showing system font styles
<VStack spacing={10} alignment="leading">
<Text modifiers={[font({ size: 34, weight: 'bold' })]}>Large Title</Text>
<Text modifiers={[font({ size: 22, weight: 'semibold' })]}>Title</Text>
<Text modifiers={[font({ size: 17, weight: 'semibold' })]}>Headline</Text>
<Text modifiers={[font({ size: 17 })]}>Body</Text>
<Text modifiers={[font({ size: 15, design: 'serif' })]}>Serif text</Text>
<Text modifiers={[font({ size: 14, design: 'monospaced' })]}>Monospaced</Text>
<Text modifiers={[font({ size: 12 }), foregroundStyle('secondaryLabel')]}>Footnote</Text>
</VStack>
// ZStack: nested squares 180/130/80 (concentric layering)
<ZStack>
<Image systemName="square.fill" size={180} color="#007AFF" />
<Image systemName="square.fill" size={130} color="#34C759" />
<Image systemName="square.fill" size={80} color="#FF9500" />
</ZStack>For "select one of N" lists, use right-aligned checkmark, NOT left-aligned <Label systemImage="checkmark">. Left icons cause horizontal jitter — text starts at different x for selected vs unselected rows. Right checkmark keeps the left edge constant:
const Row = ({ label, selected }: { label: string; selected?: boolean }) => (
<HStack>
<Text>{label}</Text>
<Spacer />
{selected ? <Image systemName="checkmark" size={18} color="#007AFF" /> : null}
</HStack>
);tint(color) from @expo/ui/swift-ui/modifiers takes a hex string. Apple system colors give a "this is real iOS" feel:
| Color | Hex | Use for |
|---|---|---|
| systemBlue | #007AFF |
accent / primary |
| systemOrange | #FF9500 |
warning / energy |
| systemGreen | #34C759 |
success / positive |
| systemRed | #FF3B30 |
destructive / critical |
| systemPink | #FF2D55 |
playful / accent |
| systemPurple | #5856D6 |
creative / accent |
| secondaryLabel | (use foregroundStyle('secondaryLabel')) |
de-emphasized text |
Leave indeterminate spinners untinted — their default secondaryLabel gray is theme-adaptive.
In the current BareExpo binary, Slider mounts but renders nothing visible (sandwich test: a Toggle | Slider | Text VStack shows the Toggle and Text but a zero-height gap between them). The native module probably needs a fresh pnpm build + pod install to pick up SliderView.swift changes.
Workaround: restore the Slider asset from git instead of re-rendering:
git checkout HEAD -- docs/public/static/images/expo-ui/slider/ios-light.webp
git checkout HEAD -- docs/public/static/images/expo-ui/slider/ios-dark.webp
# Then fit-into 1080x720 with system bg padding# fit existing image into 1080x720 with system-bg padding
sw, sh = img.size
scale = min(1080/sw, 720/sh)
nw, nh = int(sw*scale), int(sh*scale)
canvas = Image.new('RGB', (1080, 720), (242,242,247)) # systemGroupedBackground light
canvas.paste(img.resize((nw, nh), Image.LANCZOS), ((1080-nw)//2, (720-nh)//2))After all components are done:
- Delete
TestComponentfrom the bottom ofapps/bare-expo/App.tsx - Swap
<TestComponent />back to<MainNavigator />inMain's return - Remove all imports added for the screenshot session
- Verify with
git status apps/bare-expo/App.tsx— should report clean
If anything remains, the next contributor lands in your screenshot harness instead of the dev launcher.
- Don't run a second
expo start— port 8081 is already in use - The crop box must be identical for light and dark — different boxes make the image jitter when the theme switches
xcrun simctldoes NOT support taps — useagent-device clickfor any interaction- For Jetpack Compose components, the same workflow applies but use the Android emulator, save as
android-light.webp/android-dark.webp, and updatedocs/pages/.../sdk/ui/jetpack-compose/<component>.mdx displayedComponents="date"(string) works at runtime even though the type wants an array — TypeScript complains but the SwiftUI bridge accepts it. Don't fight the diagnosticButtonchildren must be<Text>elements, not bare strings —<Button>Hi</Button>errors. Uselabel="Hi"prop or<Button><Text>Hi</Text></Button>Imageusessize/colordirect props, notfont/foregroundStylemodifiers —<Image systemName="..." size={56} color="#007AFF" />ToggleusesisOn/onIsOnChange, NOTvalue/onValueChangecontrolGroupStyledoesn't exist as a modifier — use plain<ControlGroup>withtintfor color