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.

guide-layout.tsx Edit in Playground Open in new tab
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:

Propertynsh defaultWeb default
flexDirection"column"row
flexShrink01
alignContent"flex-start"stretch
alignItems"stretch"stretch
minWidth / minHeight0auto
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>
PropertyValues
flexDirection"column", "row", "column-reverse", "row-reverse"
justifyContentMain axis: "flex-start", "flex-end", "center", "space-between", "space-around", "space-evenly", "stretch"
alignItemsCross axis: "stretch", "flex-start", "flex-end", "center", "baseline"
alignSelfOne child’s cross-axis alignment; "auto" follows the parent’s alignItems
alignContentLines of a wrapping container
flexWrap"nowrap", "wrap", "wrap-reverse"
flexA single-number shorthand (below)
flexGrow, flexShrink, flexBasisAs in CSS
gap, rowGap, columnGapSpace 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

PropertyValues
width, heightPoints, a percentage of the parent ("50%"), or "auto"
minWidth, maxWidth, minHeight, maxHeightPoints or percentages
aspectRatioWidth 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>
PropertyValues
gridTemplateColumns, gridTemplateRowsCSS track lists: "repeat(3, 1fr)", "200px 1fr", minmax(), auto-fill / auto-fit, line names
gridAutoColumns, gridAutoRowsTracks 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, gridRowEndLine numbers or names
justifyItems, justifySelfHow 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;
  • left and right properties (margins, paddings, positions, borders, corners) swap sides;
  • start and end properties 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.