Guide
Styling
Every element takes a style prop: an object of camelCase properties, including CSS features such as box shadows, gradients, filters, outlines, transitions and grid. Styles are decoded in Rust and only the properties that changed are sent to the native view. You can also style with Tailwind classes; both resolve to the same native styles.
import { mount, Scroll, signal, Text, View } from "nsh";
const styles = {
tile: {
width: 96,
height: 96,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#ffffff",
},
label: { fontSize: 12, fontWeight: "600", color: "#1f2328", textAlign: "center" },
selected: { borderWidth: 3, borderColor: "#0969da" },
} as const;
function Tile(props: { label: string; style?: object; dark?: boolean }) {
return (
<View style={[styles.tile, props.style]}>
<Text style={[styles.label, props.dark && { color: "#ffffff" }]}>{props.label}</Text>
</View>
);
}
function App() {
const on = signal(false);
const picked = signal(1);
return (
<Scroll style={{ flex: 1, backgroundColor: "#f6f8fa" }}>
<View style={{ padding: 20, paddingTop: 24, gap: 20 }}>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 16, justifyContent: "center" }}>
<Tile label="boxShadow" style={{ boxShadow: "0 6px 16px rgba(0,0,0,0.18)" }} />
<Tile label="dashed border" style={{ borderWidth: 2, borderStyle: "dashed", borderColor: "#8250df" }} />
<Tile label="gradient" dark style={{ backgroundImage: "linear-gradient(135deg, #54aeff, #8250df)" }} />
<Tile label="radial" style={{ backgroundImage: "radial-gradient(circle, #fff8c5, #fd8c73)" }} />
<Tile label="corners" style={{ borderTopLeftRadius: 40, borderBottomRightRadius: 40, backgroundColor: "#dafbe1" }} />
<Tile label="continuous" dark style={{ borderRadius: 28, borderCurve: "continuous", backgroundColor: "#1a7f37" }} />
<Tile label="rotate + scale" style={{ backgroundColor: "#ddf4ff", transform: [{ rotate: "-8deg" }, { scale: 0.9 }] }} />
<Tile label="outline" style={{ outlineWidth: 2, outlineColor: "#bf3989", outlineOffset: 3 }} />
<Tile label="opacity 0.5" dark style={{ backgroundColor: "#1f2328", opacity: 0.5 }} />
</View>
<View style={{ gap: 10 }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: "#6e7781" }}>STYLE ARRAYS: TAP TO SELECT</Text>
<View style={{ flexDirection: "row", gap: 10 }}>
<Each of={["Small", "Medium", "Large"]}>
{(size, i) => (
<View
onPress={() => picked.set(picked() === i() ? -1 : i())}
style={[
{ flex: 1, padding: 12, borderRadius: 10, backgroundColor: "#ffffff", alignItems: "center", borderWidth: 3, borderColor: "transparent" },
picked() === i() && styles.selected,
]}
>
<Text style={{ color: "#1f2328" }}>{size()}</Text>
</View>
)}
</Each>
</View>
</View>
<View style={{ gap: 10 }}>
<Text style={{ fontSize: 13, fontWeight: "600", color: "#6e7781" }}>TRANSITIONS: TAP THE BOX</Text>
<View
onPress={() => on.set(!on())}
style={{
height: 72,
borderRadius: on() ? 36 : 12,
backgroundColor: on() ? "#8250df" : "#54aeff",
transform: [{ translateX: on() ? 120 : 0 }],
width: 150,
alignItems: "center",
justifyContent: "center",
transitionProperty: "all",
transitionDuration: 400,
transitionTimingFunction: "ease-in-out",
}}
>
<Text style={{ color: "#ffffff", fontWeight: "600" }}>{on() ? "On" : "Off"}</Text>
</View>
</View>
</View>
</Scroll>
);
}
mount(App);<View
style={{
padding: 16,
borderRadius: 12,
backgroundColor: "#ffffff",
borderWidth: 1,
borderColor: "#d0d7de",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.12)",
}}
>
<Text style={{ fontSize: 17, fontWeight: "600", color: "#1f2328" }}>Card</Text>
</View>
This page covers how styles combine and the visual properties. Layout properties (flexbox, grid, sizes, positions) are on Layout, text properties on Text, and every property with its type is listed in the style reference.
#The style prop
style takes an object, or an array of objects merged left to right. Falsy entries (false, null, undefined) are skipped, which makes conditional styles short:
const styles = {
button: { paddingHorizontal: 16, paddingVertical: 10, borderRadius: 10, backgroundColor: "#0969da" },
disabled: { opacity: 0.4 },
danger: { backgroundColor: "#cf222e" },
} as const;
<View style={[styles.button, props.danger && styles.danger, busy() && styles.disabled]} />
Define shared styles as constants outside components (with as const, so TypeScript keeps literal types such as "row"). There is no StyleSheet.create: a plain object is all it takes, and styles are parsed in Rust each time they are set, from objects nobody needs to register.
A style expression that reads signals is rebuilt when they change, and nsh sends only the properties whose values differ:
<View style={{ backgroundColor: selected() ? "#ddf4ff" : "#ffffff", padding: 12 }} />
When an element has both, style wins over class: classes are applied first, then the inline style on top.
#Units and values
- Numbers are points (density-independent pixels):
width: 100is 100pt, which is 200 or 300 physical pixels on a 2× or 3× screen. - Percentages (
"50%") are relative to the parent, for sizes, positions, margins and paddings (Layout). - Angles are degrees as numbers or strings with a unit:
45,"45deg","0.5rad". - Durations are milliseconds as numbers, or strings:
"150ms","0.2s".
#Defaults
nsh’s defaults differ from the web’s:
| Default | |
|---|---|
flexDirection | "column" |
flexShrink | 0 (children do not shrink unless asked) |
alignContent | "flex-start" |
position | "relative" |
minWidth / minHeight | 0 |
backgroundColor | transparent |
| Text | black, 14pt, weight 400 |
shadowOffset / shadowRadius / shadowOpacity | { width: 0, height: -3 } / 3 / 0 |
New views carry no style at all until you give them one, so unstyled wrappers cost nothing on the native side.
#Colors
Color properties accept CSS color strings:
| Form | Example |
|---|---|
| Hex | "#09f", "#09fc", "#0099ff", "#0099ffcc" |
rgb() / rgba() | "rgb(0, 153, 255)", "rgba(0, 0, 0, 0.5)", "rgb(0 0 0 / 10%)" |
hsl() / hsla() | "hsl(210 100% 50%)" |
oklch() / oklab() | "oklch(62.3% 0.214 259.8)" |
| Named | "white", "black", "transparent", "rebeccapurple", … |
For dark mode, read colorScheme() from nsh/device, an accessor that follows the system appearance, or use Tailwind’s dark: variant:
import { Text, View } from "nsh";
import { colorScheme } from "nsh/device";
const palette = () =>
colorScheme() === "dark"
? { background: "#0d1117", text: "#e6edf3" }
: { background: "#ffffff", text: "#1f2328" };
<View style={{ flex: 1, backgroundColor: palette().background }}>
<Text style={{ color: palette().text }}>Follows the system appearance</Text>
</View>
The default text color is black in both appearances: set colors explicitly in apps that support dark mode.
#Borders and corners
<View
style={{
borderWidth: 1,
borderColor: "#d0d7de",
borderStyle: "dashed", // "solid" | "dashed" | "dotted"
borderRadius: 12,
borderTopLeftRadius: 24, // per corner, overrides borderRadius
borderBottomWidth: 3, // per side
borderBottomColor: "#0969da",
}}
/>
- Widths and colors per side:
borderTopWidth,borderRightWidth, …,borderTopColor, …; and the direction-awareborderStartWidth,borderEndWidth,borderStartColor,borderEndColor. - Radii per corner:
borderTopLeftRadius,borderTopRightRadius,borderBottomRightRadius,borderBottomLeftRadius, andborderTopStartRadius/borderTopEndRadius/borderBottomStartRadius/borderBottomEndRadius, which follow the layout direction. borderCurve: "continuous"draws corners whose curvature eases into the edges, like iOS app icons, on both platforms. A pill (borderRadiusof half the height) stays a pill.overflow: "hidden"clips children to the rounded shape.
Radii larger than half the side are clamped, so borderRadius: 9999 makes a circle or a pill.
#Shadows
Three ways to cast a shadow:
// The shadow* properties
<View style={{ shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 8, shadowOffset: { width: 0, height: 4 } }} />
// CSS box-shadow: several, comma separated, with spread and inset
<View style={{ boxShadow: "0 1px 2px rgba(0,0,0,0.08), 0 8px 24px rgba(0,0,0,0.12)" }} />
<View style={{ boxShadow: "inset 0 2px 4px rgba(0,0,0,0.15)" }} />
// Android's material elevation (a prop, not a style)
<View android={{ elevation: 6 }} style={{ backgroundColor: "#ffffff", borderRadius: 12 }} />
boxShadow follows CSS: offsetX offsetY blur spread color, inset for inner shadows. Shadows follow the view’s rounded shape.
#Gradients
backgroundImage takes CSS gradients, drawn over the background color. Several gradients are separated by commas.
<View style={{ height: 120, borderRadius: 16, backgroundImage: "linear-gradient(135deg, #54aeff, #8250df)" }} />
<View style={{ backgroundImage: "radial-gradient(circle at top left, #fff8c5, #fd8c73)" }} />
<View style={{ backgroundImage: "conic-gradient(red, yellow, lime, aqua, blue, magenta, red)" }} />
experimental_backgroundImage is accepted too, as an alias of backgroundImage. Text elements do not draw gradients on iOS.
#Filters and blending
| Property | Value |
|---|---|
filter | CSS filters of the view and its content: brightness(), contrast(), grayscale(), hue-rotate(), invert(), saturate(), sepia(), opacity(), blur(), drop-shadow(); several separated by spaces |
backdropFilter | Filters of what is behind the view: "blur(10px)" (frosted glass) |
mixBlendMode | How the view blends with what is behind it: "multiply", "screen", "overlay", … |
<Image src={photo} style={{ width: 120, height: 120, filter: "grayscale(1) contrast(1.2)" }} />
<View style={{ position: "absolute", bottom: 0, left: 0, right: 0, padding: 12, backgroundColor: "rgba(255,255,255,0.4)", backdropFilter: "blur(12px)" }} />
Platform support differs:
| iOS | Android | |
|---|---|---|
filter on views | opacity, brightness up to 1 and drop-shadow only | All, from Android 12 (API 31); color filters only below |
filter on images | Every color filter and blur | As views |
backdropFilter | A system blur material, approximating the radius | From Android 12 (API 31) |
mixBlendMode | Yes | From Android 10 (API 29) |
#Outlines, opacity and visibility
outlineWidth,outlineColor,outlineStyle,outlineOffset: a line outside the border that takes no space (negative offsets draw inside).opacity: 0 to 1, applied to the view and its children.visibility: "hidden": the view is not drawn but keeps its space.display: "none"removes it from layout.zIndex: the order among siblings, also the native stacking order (stable for equal values).cursor: the pointer shape over the view, for pointers and desktops.
#Transforms
transform is a list of steps applied right to left, like CSS:
<View style={{ transform: [{ translateX: 20 }, { rotate: "-8deg" }, { scale: 0.9 }] }} />
<View style={{ transform: [{ perspective: 600 }, { rotateY: "35deg" }], backfaceVisibility: "hidden" }} />
<View style={{ transformOrigin: "top left", transform: [{ rotate: "30deg" }] }} />
Steps: translateX, translateY, scale, scaleX, scaleY, rotate (rotateZ), rotateX, rotateY, skewX, skewY, perspective. transformOrigin takes keywords, percentages and lengths ("top left", "50% 0", [10, 20]); the default is the center. Transforms do not affect layout: siblings are placed as if the view were untransformed.
Android has no skew transforms.
#Transitions
A view animates changes of its own style when you name what to animate. The animation runs natively; no JavaScript runs per frame.
<View
onPress={() => open.set(!open())}
style={{
width: 150,
height: 72,
borderRadius: open() ? 36 : 12,
backgroundColor: open() ? "#8250df" : "#54aeff",
transform: [{ translateX: open() ? 120 : 0 }],
transitionProperty: "all",
transitionDuration: 300,
transitionTimingFunction: "ease-in-out",
}}
/>
| Property | Value |
|---|---|
transitionProperty | What animates: "all", or names separated by commas: colors ("colors", "backgroundColor", "borderColor", …), "opacity", "transform", "boxShadow", "borderRadius" / "borderWidth" |
transitionDuration | 300, "300ms", "0.3s" |
transitionDelay | As the duration |
transitionTimingFunction | "linear", "ease", "ease-in", "ease-out", "ease-in-out", "cubic-bezier(x1, y1, x2, y2)" |
Sizes and positions are layout, not style, and do not transition; animate layout changes with LayoutAnimation or drive values with native animations (Animation).
#Keyframe animations
animationName runs one of Tailwind’s built-in keyframe animations natively: "spin", "ping", "pulse" or "bounce", with animationDuration, animationDelay, animationTimingFunction and animationIterationCount (a number or "infinite"):
<View style={{ width: 24, height: 24, borderRadius: 4, backgroundColor: "#0969da", animationName: "spin", animationDuration: 1000, animationIterationCount: "infinite", animationTimingFunction: "linear" }} />
#Press feedback
Pressed states are handled natively, without a round trip to JavaScript:
| Prop | Effect while pressed |
|---|---|
activeOpacity={0.6} | The view fades to this opacity |
underlayColor="#eaeef2" | The background becomes this color and the children fade to underlayOpacity (default 0.85) |
android={{ ripple: { color: "#0002" } }} | Android’s material ripple |
class="active:bg-zinc-100 active:scale-95" | Any style, through Tailwind’s active: variant |
See Touch and gestures.
#Related
- Tailwind: the same styles as classes, with variants for dark mode, pressed states and breakpoints.
- Layout and Text.
- Style reference: every style property and its type.