Guide

Tailwind

Every element accepts Tailwind CSS classes in class, resolved natively against Tailwind 4’s default theme:

guide-tailwind.tsx Edit in Playground Open in new tab
import { mount, Scroll, signal, Text, View } from "nsh";

function App() {
  const count = signal(0);
  const busy = signal(false);
  const tab = signal("Day");
  const tabs = ["Day", "Week", "Month"];

  return (
    <Scroll class="flex-1 bg-zinc-100 dark:bg-zinc-900">
      <View class="gap-5 p-5 pt-6">
        <Text class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Tailwind classes</Text>

        <View class="flex-row flex-wrap gap-2">
          <View
            onPress={() => count.update((n) => n + 1)}
            class="rounded-lg bg-indigo-600 px-4 py-2 transition-colors duration-150 active:bg-indigo-800"
          >
            <Text class="font-medium text-white">Pressed {count()}×</Text>
          </View>
          <View
            onPress={() => busy.set(!busy())}
            class="rounded-lg border border-zinc-300 px-4 py-2 transition active:scale-95 active:bg-zinc-200 dark:border-zinc-600"
          >
            <Text class="font-medium text-zinc-900 dark:text-white">{busy() ? "Enable" : "Disable"}</Text>
          </View>
          <View disabled={busy()} onPress={() => count.set(0)} class="rounded-lg bg-emerald-500 px-4 py-2 active:bg-emerald-700 disabled:opacity-40">
            <Text class="font-medium text-white">Reset</Text>
          </View>
        </View>

        <View class="flex-row rounded-xl bg-zinc-200 p-1 dark:bg-zinc-800">
          <Each of={tabs}>
            {(t) => (
              <View
                onPress={() => tab.set(t())}
                class="flex-1 items-center rounded-lg py-2 transition-colors"
                classList={{ "bg-white shadow-sm dark:bg-zinc-600": tab() === t() }}
              >
                <Text class="text-sm font-medium" classList={{ "text-zinc-900 dark:text-white": tab() === t(), "text-zinc-500": tab() !== t() }}>
                  {t()}
                </Text>
              </View>
            )}
          </Each>
        </View>

        <View class="overflow-hidden rounded-2xl bg-linear-to-br from-indigo-500 via-fuchsia-500 to-orange-400 p-5 shadow-xl shadow-indigo-500/40">
          <Text class="text-xl font-bold text-white">{tab()} view</Text>
          <Text class="mt-1 text-sm text-white/80">bg-linear-to-br from-indigo-500 via-fuchsia-500 to-orange-400</Text>
        </View>

        <View class="grid grid-cols-3 gap-2">
          <View class="col-span-2 h-14 items-center justify-center rounded-lg bg-sky-200">
            <Text class="text-xs text-sky-900">col-span-2</Text>
          </View>
          <View class="h-14 rounded-lg bg-sky-300" />
          <View class="h-14 rounded-lg bg-sky-400" />
          <View class="row-span-2 rounded-lg bg-sky-500" />
          <View class="h-14 rounded-lg bg-sky-600" />
          <View class="h-14 rounded-lg bg-sky-700" />
          <View class="h-14 rounded-lg bg-sky-800" />
        </View>

        <View class="overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
          <Each of={["Inbox", "Drafts", "Sent", "Archive"]}>
            {(item) => (
              <View class="flex-row items-center justify-between border-t border-zinc-200 px-4 py-3 first:border-t-0 even:bg-zinc-50 active:bg-indigo-50 dark:border-zinc-700 dark:even:bg-zinc-900">
                <Text class="text-zinc-800 dark:text-zinc-100">{item()}</Text>
                <Text class="text-zinc-400">›</Text>
              </View>
            )}
          </Each>
        </View>

        <View class="flex-row items-center justify-around py-2">
          <View class="size-8 animate-spin rounded-md bg-indigo-500" />
          <View class="size-8 animate-bounce rounded-full bg-orange-400" />
          <View class="h-8 w-14 animate-pulse rounded-md bg-zinc-300" />
        </View>
      </View>
    </Scroll>
  );
}

mount(App);
<View class="flex-row items-center gap-3 rounded-2xl bg-white p-4 shadow-lg active:scale-[0.98] dark:bg-zinc-800">
  <Text class="text-base font-semibold text-zinc-900 dark:text-white">Tailwind on native views</Text>
</View>

No CSS is generated and nothing runs in JavaScript: the string is passed to the core, which parses each distinct class string once (and caches it), turns it into style rules, and applies them. Variants that depend on state (pressed, dark mode, window width) are re-applied by the core when that state changes, and pressed and focused states are tracked natively, so active: feedback has no JavaScript round trip.

Classes and the style prop produce the same native styles and can be combined; this page covers what is specific to classes. The Tailwind reference lists every supported utility.

#class and classList

<View
  class="rounded-lg px-4 py-2 transition-colors"
  classList={{ "bg-indigo-600": selected(), "bg-zinc-200": !selected(), "opacity-50": busy() }}
/>
  • class is a string of classes. It can be dynamic: class={`h-6 rounded bg-${color()}-500`} works, because classes are resolved at run time.
  • classList maps classes to conditions: a class applies while its value is truthy. It is applied after class.

How conflicts resolve:

  1. Later classes win over earlier ones in the same string: "p-2 p-4" is p-4. There is no need for tailwind-merge: a component can append the class its caller passes.
  2. Variant classes apply after plain ones, ordered by Tailwind’s variant order (hover: before active:, breakpoints ascending, dark: last), whatever their position in the string. As in CSS, odd:bg-a last:bg-b gives an odd last row bg-a, because Tailwind sorts odd after last.
  3. style wins over classes.
  4. ! makes a class important: !p-0.

Components from nsh/ui/mobile accept class and classList too; your classes are merged after the component’s own styles and win at equal rank.

#Variants

VariantApplies when
active:The view is pressed
hover:A pointer is over the view (iPad and Mac pointers)
focus:, focus-visible:An input has focus
focus-within:The view or a descendant has focus
disabled:, enabled:The element’s disabled prop is true / false
group-active:, group-hover:, group-focus:, group-focus-within:, group-disabled:The nearest ancestor with the group class is in that state
group-active/name: …The same, for an ancestor marked group/name
first:, last:, odd:, even:, only:The view’s position among its siblings
placeholder:Styles an input’s placeholder (placeholder:text-zinc-400)
dark:The system appearance is dark
sm:, md:, lg:, xl:, 2xl:The window is at least 640, 768, 1024, 1280, 1536 points wide
max-sm:, max-md:, …The window is narrower than the breakpoint
min-[600px]:, max-[600px]:Arbitrary breakpoints
portrait:, landscape:The window’s orientation
ios:, android:The platform
rtl:, ltr:The layout direction
motion-safe:, motion-reduce:The user’s reduce motion setting

Variants combine: dark:active:bg-zinc-700, md:landscape:flex-row.

<View class="group flex-row items-center gap-3 rounded-xl bg-white p-3 active:bg-zinc-50">
  <Text class="flex-1 text-zinc-900">Settings</Text>
  <Text class="text-zinc-300 transition-transform group-active:translate-x-1">›</Text>
</View>

disabled on any element applies disabled: classes and removes its press handlers from what is mounted, so it shows no press feedback either:

<View disabled={saving()} onPress={save} class="rounded-lg bg-emerald-500 px-4 py-2 active:bg-emerald-600 disabled:opacity-40">
  <Text class="font-medium text-white">Save</Text>
</View>

Breakpoints compare against the window (the surface nsh renders into), not the device: in an iPad split view or a resizable window, they follow the window’s width.

#What differs from Tailwind on the web

  • Units are points. p-4 is 16 points (1 spacing step = 4pt, 1rem = 16pt). Arbitrary values in px are points too: p-[13px] is 13pt.
  • flex is a row, as in CSS: class="flex" lays out its children in a row unless a direction class says otherwise (anywhere in the string: flex-col flex is a column). A view without flex or inline-flex keeps nsh’s default column. block and flow-root use block layout; inline and inline-block lay out as views (there is no inline formatting context).
  • Text classes on views are inherited. text-*, font-*, leading-*, tracking-* and the other text utilities on a view pass to the <Text> and <Input> elements inside it, as CSS inheritance does: <View class="text-white"> makes its labels white.
  • space-x-* / space-y-* are gaps (gap-x / gap-y).
  • Classes for settings that are props in nsh: line-clamp-*, truncate, whitespace-nowrap, pointer-events-*, object-* (image resize mode), select-text, caret-* and placeholder:text-* work, and lose to the explicit props (numberOfLines, pointerEvents, resizeMode, …) when both are set.
  • Not supported: peer-* variants, pseudo-elements (before:, after:), arbitrary properties ([mask-type:luminance]). Web-only utilities that need nothing natively (antialiased, will-change-*, touch-*, scroll-*, snap-*, overscroll-*, box-border, …) are accepted and ignored.

Everything else works as documented by Tailwind 4: the color palette, opacity modifiers (bg-black/40), arbitrary values (bg-[#34c759], w-[72px], grid-cols-[1fr_2fr]), fractions (w-1/2, translate-x-1/2), shadows and rings, gradients (bg-linear-to-r, bg-radial, bg-conic with from- / via- / to-), filters, backdrop-blur-*, mix-blend-*, transforms, transition-* and duration-*, animate-spin / ping / pulse / bounce, and grid. The platform limits of filters and blending apply to classes too.

#Theme

Classes resolve against Tailwind 4’s default theme. Extend it in nsh.json under "tailwind"; your entries are added to the defaults (or replace the default of the same name):

{
  "tailwind": {
    "colors": {
      "brand": { "50": "#f5f3ff", "500": "#7c3aed", "600": "#6d28d9", "DEFAULT": "#7c3aed" },
      "ink": "#1f2328"
    },
    "radius": { "card": 14 },
    "fontFamily": { "display": "Pacifico" },
    "breakpoints": { "tablet": 600 }
  }
}

With this theme, bg-brand-500, text-brand (the DEFAULT shade), border-ink, rounded-card, font-display and tablet:flex-row are valid classes.

KeyValuesMakes
colorsA CSS color, or an object of shades ("DEFAULT" for the bare name)bg-*, text-*, border-*, from-*, ring-*, …
spacingPoints per spacing step (default 4)p-*, m-*, gap-*, w-*, …
remPoints per rem in arbitrary values (default 16)
breakpointsPointsname: variants
containerPointsmax-w-* sizes
fontSizePoints, or [size, lineHeight] with the line height a multiple of the sizetext-*
fontWeight1–1000font-*
fontFamilyA family name (a system family or an imported font’s name)font-*
trackingEmstracking-*
leadingMultiples of the font sizeleading-*
radiusPointsrounded-*
shadow, insetShadow, dropShadowA CSS box-shadowshadow-*, inset-shadow-*, drop-shadow-*
blurPointsblur-*, backdrop-blur-*
perspectivePointsperspective-*
aspectA ratio (1.5)aspect-*
ease[x1, y1, x2, y2]ease-*

Unknown keys and invalid values are build errors (nsh.json: tailwind: unknown theme key 'colours'). The theme is compiled into the bundle and applied before the first frame.

The theme only shapes your own classes. The components of nsh/ui/mobile take their colors from the component theme, "theme" in nsh.json (Components).

#The class check

nsh build, nsh dev, nsh run and nsh trace check the static class names in your JSX (string literals in class and the keys of classList) with the same parser and theme the runtime uses, and warn about classes that do not exist:

 warn nsh::build App.tsx:12:36: unknown class `bg-brand-550`

Warnings do not fail the build. Classes built at run time (bg-${color()}-500) cannot be checked when building; the runtime logs each unknown class once when it first sees it (unknown classes: ...).

#Classes or styles?

Both are resolved natively and cost about the same. Classes are shorter and give you variants (pressed states, dark mode, breakpoints) without code. Inline styles take values computed in JavaScript and accept native animated values (opacity: fade, transform: [{ translateX: x }]). Most apps use classes for the static look and style for what is computed or animated.

  • Styling: the style prop and the platform limits of effects.
  • Tailwind reference: every utility and variant.
  • Layout: flexbox and grid, also available as classes.