Guide
Layout
nsh lays out every element with Taffy, a Rust implementation of CSS flexbox, grid and block layout, configured so that children stack vertically and do not shrink by default. Layout runs in the core, after each JavaScript turn, and only the frames that changed are sent to the native views. Text is measured by the platform’s text engine on the same thread, so layout never waits for the main thread.
import { mount, Scroll, signal, Text, View } from "nsh";
type Direction = "column" | "row";
type Justify = "flex-start" | "center" | "flex-end" | "space-between" | "space-evenly";
type Align = "stretch" | "flex-start" | "center" | "flex-end";
function Choice<T extends string>(props: { label: string; options: readonly T[]; value: T; onChange: (v: T) => void }) {
return (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 12, fontWeight: "600", color: "#6e7781" }}>{props.label}</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
<Each of={props.options}>
{(o) => (
<View
onPress={() => props.onChange(o())}
style={{ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, backgroundColor: props.value === o() ? "#0969da" : "#ffffff" }}
>
<Text style={{ fontSize: 12, color: props.value === o() ? "#ffffff" : "#1f2328" }}>{o()}</Text>
</View>
)}
</Each>
</View>
</View>
);
}
function App() {
const direction = signal<Direction>("row");
const justify = signal<Justify>("flex-start");
const align = signal<Align>("stretch");
const colors = ["#fd8c73", "#54aeff", "#4ac26b"];
return (
<Scroll style={{ flex: 1, backgroundColor: "#f6f8fa" }}>
<View style={{ padding: 20, paddingTop: 24, gap: 14 }}>
<View
onPress={() => direction.set(direction() === "row" ? "column" : "row")}
style={{ alignSelf: "flex-start", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: "#0969da" }}
>
<Text style={{ fontSize: 13, color: "#ffffff" }}>flexDirection: {direction()} (tap to switch)</Text>
</View>
<Choice
label="justifyContent"
options={["flex-start", "center", "flex-end", "space-between", "space-evenly"] as const}
value={justify()}
onChange={(v) => justify.set(v)}
/>
<Choice label="alignItems" options={["stretch", "flex-start", "center", "flex-end"] as const} value={align()} onChange={(v) => align.set(v)} />
<View
style={{
height: 220,
padding: 10,
gap: 8,
borderRadius: 12,
backgroundColor: "#ffffff",
flexDirection: direction(),
justifyContent: justify(),
alignItems: align(),
}}
>
<Each of={colors}>
{(c, i) => (
<View style={{ minWidth: 44, minHeight: 44, padding: 6, borderRadius: 8, backgroundColor: c() }}>
<Text style={{ color: "#ffffff", fontWeight: "700" }}>{i() + 1}</Text>
</View>
)}
</Each>
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: "#6e7781" }}>flex: 1 / 2 / fixed, percentages, aspectRatio</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ flex: 1, height: 36, borderRadius: 6, backgroundColor: "#c8e1ff" }} />
<View style={{ flex: 2, height: 36, borderRadius: 6, backgroundColor: "#79b8ff" }} />
<View style={{ width: 36, height: 36, borderRadius: 6, backgroundColor: "#0366d6" }} />
</View>
<View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
<View style={{ width: "30%", aspectRatio: 1, borderRadius: 8, backgroundColor: "#ffdfb6" }} />
<View style={{ width: "40%", aspectRatio: 16 / 9, borderRadius: 8, backgroundColor: "#ffab70" }} />
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: "#6e7781" }}>grid</Text>
<View style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6 }}>
<View style={{ gridColumn: "span 2", height: 40, borderRadius: 6, backgroundColor: "#d8b9ff" }} />
<View style={{ height: 40, borderRadius: 6, backgroundColor: "#b083f0" }} />
<View style={{ gridRow: "span 2", borderRadius: 6, backgroundColor: "#8250df" }} />
<View style={{ gridColumn: "1 / 4", height: 40, borderRadius: 6, backgroundColor: "#6639ba" }} />
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: "#6e7781" }}>position: absolute</Text>
<View style={{ height: 100, borderRadius: 12, backgroundColor: "#ffffff" }}>
<View style={{ position: "absolute", top: 8, left: 8, width: 60, height: 40, borderRadius: 6, backgroundColor: "#4ac26b" }} />
<View style={{ position: "absolute", right: 8, bottom: 8, width: "40%", height: 30, borderRadius: 6, backgroundColor: "#fd8c73" }} />
<View style={{ position: "absolute", top: 0, bottom: 0, left: "50%", width: 2, backgroundColor: "#d0d7de" }} />
</View>
</View>
</Scroll>
);
}
mount(App);The layout properties below are style properties. Each has a Tailwind equivalent (flex-row, gap-4, grid-cols-3, absolute inset-0, …).
#Flexbox
A view lays out its children with flexbox. The defaults differ from the web’s:
| Property | nsh default | Web default |
|---|---|---|
flexDirection | "column" | row |
flexShrink | 0 | 1 |
alignContent | "flex-start" | stretch |
alignItems | "stretch" | stretch |
minWidth / minHeight | 0 | auto |
position | "relative" | static |
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, padding: 16 }}>
<Image src={avatar} style={{ width: 40, height: 40, borderRadius: 20 }} />
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontWeight: "600" }}>{props.name}</Text>
<Text style={{ color: "#6e7781" }} numberOfLines={1}>{props.message}</Text>
</View>
<Text style={{ color: "#6e7781" }}>{props.time}</Text>
</View>
| Property | Values |
|---|---|
flexDirection | "column", "row", "column-reverse", "row-reverse" |
justifyContent | Main axis: "flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly", "stretch" |
alignItems | Cross axis: "stretch", "flex-start", "flex-end", "center", "baseline" |
alignSelf | One child’s cross-axis alignment; "auto" follows the parent’s alignItems |
alignContent | Lines of a wrapping container |
flexWrap | "nowrap", "wrap", "wrap-reverse" |
flex | A single-number shorthand (below) |
flexGrow, flexShrink, flexBasis | As in CSS |
gap, rowGap, columnGap | Space between children |
flex takes one number and is not the CSS shorthand: a positive number n means grow by n, shrink by 1 and start from a basis of 0, so flex: 1 siblings share the space equally whatever their content; flex: 0 (the default) sizes the view to its content; a negative number shrinks it by that much down to its minimum.
<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ flex: 1, height: 36, backgroundColor: "#c8e1ff" }} /> {/* one share */}
<View style={{ flex: 2, height: 36, backgroundColor: "#79b8ff" }} /> {/* two shares */}
<View style={{ width: 36, height: 36, backgroundColor: "#0366d6" }} /> {/* fixed */}
</View>
Because minimum sizes default to 0, a flex: 1 text in a row shrinks and wraps instead of pushing its siblings out.
#Sizes
| Property | Values |
|---|---|
width, height | Points, a percentage of the parent ("50%"), or "auto" |
minWidth, maxWidth, minHeight, maxHeight | Points or percentages |
aspectRatio | Width divided by height: 1, 16 / 9 |
padding, paddingHorizontal, paddingVertical, paddingTop, … | Points or percentages |
margin, marginHorizontal, marginVertical, marginTop, … | Points, percentages, or "auto" |
Without a size, a view is as big as its content (plus padding), stretched across its parent’s cross axis by the default alignItems: "stretch". Percentages are relative to the parent’s size (for padding and margin, as in CSS, to its width).
aspectRatio sizes the other dimension from the one that is known:
<View style={{ width: "100%", aspectRatio: 16 / 9, backgroundColor: "#000000" }} />
Images imported from files have a natural size and lay out at it when no size is given (Images and assets). Remote images have no size until they load: give them one.
#Grid
display: "grid" lays out children in CSS grid tracks:
<View style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
<View style={{ gridColumn: "span 2", height: 60, backgroundColor: "#d8b9ff" }} />
<View style={{ height: 60, backgroundColor: "#b083f0" }} />
<View style={{ gridRow: "span 2", backgroundColor: "#8250df" }} />
<View style={{ gridColumn: "2 / 4", height: 60, backgroundColor: "#6639ba" }} />
</View>
| Property | Values |
|---|---|
gridTemplateColumns, gridTemplateRows | CSS track lists: "repeat(3, 1fr)", "200px 1fr", minmax(), auto-fill / auto-fit, line names |
gridAutoColumns, gridAutoRows | Tracks of implicitly created columns and rows |
gridAutoFlow | "row", "column", "dense", "row dense", "column dense" |
gridColumn, gridRow | "1 / 3", "span 2", a line number or name |
gridColumnStart, gridColumnEnd, gridRowStart, gridRowEnd | Line numbers or names |
justifyItems, justifySelf | How items sit in their areas along the row |
display: "block" uses CSS block layout (children stacked, each as wide as the container), and display: "none" removes the view from layout.
#Absolute positioning
position: "absolute" takes a view out of the flow and places it by top, right, bottom and left, relative to its parent:
<View style={{ height: 200 }}>
<Image src={cover} style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }} />
<View style={{ position: "absolute", left: 12, right: 12, bottom: 12 }}>
<Text style={{ color: "#ffffff", fontWeight: "700" }}>Caption</Text>
</View>
</View>
Offsets accept points and percentages. A view with left and right (or top and bottom) and no size stretches between them. Later siblings are drawn above earlier ones; zIndex changes the order.
#Measuring
onLayout reports a view’s frame, relative to its parent, after each layout in which it changed:
const width = signal(0);
<View onLayout={(e) => width.set(e.width)}>
<Text>{width()} points wide</Text>
</View>
The event is { x, y, width, height } in points. For the whole window, read windowSize() from nsh/device, an accessor that follows rotation and window resizing:
import { View } from "nsh";
import { windowSize } from "nsh/device";
<View style={{ flexDirection: windowSize().width > 600 ? "row" : "column" }} />
Tailwind’s breakpoints (sm:, md:, …) and portrait: / landscape: give the same result without code.
#Safe areas
The root view covers the whole screen, under the status bar and the home indicator. Wrap screens in SafeAreaView from nsh/ui/mobile, which pads its content by the safe area insets, or read the insets with safeArea() / useSafeAreaInsets() and apply them yourself. Screens of a navigation stack already sit below their header.
#Right to left
I18nManager from nsh/device controls the layout direction of the whole app. When the app lays out right to left (the system language is right-to-left, or I18nManager.forceRTL(true)):
- rows run from the right;
leftandrightproperties (margins, paddings, positions, borders, corners) swap sides;startandendproperties always mean the leading and trailing side:marginStart,marginEnd,paddingStart,paddingEnd,start,end,borderStartWidth,borderTopStartRadius, …;- text with
textAlign: "auto"aligns right, and native controls mirror.
import { Text, View } from "nsh";
import { I18nManager } from "nsh/device";
<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ marginStart: 16, borderTopStartRadius: 12, width: 44, height: 44, backgroundColor: "#54aeff" }} />
</View>
<View onPress={() => I18nManager.forceRTL(!I18nManager.isRTL)}>
<Text>{I18nManager.isRTL ? "Right to left" : "Left to right"}</Text>
</View>
I18nManager.isRTL is reactive and changes apply at once, without a restart. The direction style ("inherit", "ltr", "rtl") overrides the direction for one view and its children. On Android, apps that embed nsh need android:supportsRtl="true" in their manifest for native controls to mirror.
#Related
- Styling: how styles combine.
- Scrolling and Lists: content larger than the screen.
- Style reference: every layout property and its type.