# Styling with className
PS5 React compiles a **subset of Tailwind CSS v3 utilities** into style objects
at build time, similar in spirit to NativeWind but with no runtime. The
renderer still receives only JavaScript style objects; no CSS is parsed on the
PS5. This is not full Tailwind compatibility: every supported utility is listed
below, and anything else fails the build with a source location and a reason.
## Quick example
```jsx
import {AppRegistry, View, Text, FocusScope, tw, useIsFocused} from '@ps5-react/core';
const title = tw`text-4xl font-bold text-white`;
// Text cannot read focus itself; it asks the focusable View around it.
function Label({children}) {
const focused = useIsFocused();
return {children};
}
function Menu() {
return (
Library
{['Games', 'Media', 'Settings'].map(label => (
))}
);
}
AppRegistry.registerComponent('menu', () => Menu);
```
## How it works
```text
index.jsx ── esbuild ── Babel plugin (tools/tailwind) ── bundle with literal styles
↑
tailwind.config.js + app.json render size
```
1. During bundling, a Babel plugin visits every `className` attribute and every
`tw` template imported from `@ps5-react/core`.
2. Each class is resolved against the theme (`tools/tailwind/theme.mjs`, merged
with the app's `tailwind.config.js`) and converted to engine style keys.
3. Lengths are scaled to the render resolution and rounded to integers.
4. The attribute is replaced by `style={...}` containing literal objects.
Conditions and variants become conditional entries in a style array.
5. `className` and the class strings do not reach the bundle or the PS5 runtime.
`className` works on any JSX element: host primitives (`View`, `Text`, `Image`,
`ScrollView`) and your own components. A custom component receives
a `style` prop, possibly an array, and must forward it to a primitive.
### Precedence
- Later classes win over earlier ones in the same string.
- Conditional parts (`cond ? 'a' : 'b'`, `cond && 'a'`) apply after the static
classes of the same expression.
- Variant classes (`focused:...`) apply after base and conditional classes.
- An explicit `style` prop always wins: `className` plus `style` compiles to
`style={[compiled, style]}`, or, on a focusable element, to a style function
that returns `[...compiled, style]` (see [Focus state](#focus-state)).
- The `!` important modifier is rejected; precedence above covers its uses.
## Scaling
Utilities are written in **logical pixels** at a base width of 1280: `1rem` is
16px and spacing unit `1` is 4px, as in Tailwind. The build multiplies every
length by `render.width / baseWidth` and rounds it:
```text
physical px = round(logical px × render.width / baseWidth)
```
`render.width` comes from `app.json`; `baseWidth` from `tailwind.config.js`
(default 1280). An app with `"resolution": "auto"`, like the starter, is compiled
once per layout: `p-4` (16px) becomes 24px at 1920×1080 and 48px at 3840×2160.
`w-screen`, `h-screen`, `min-w-screen`, `max-w-screen`, `min-h-screen`, and
`max-h-screen` use the exact render width or height. Unitless values (opacity,
z-index, flex, scale, rotation, aspect ratio, relative line height and tracking)
are not scaled. Pixel `tracking-[…]` values are scaled but kept to two decimals.
Every font size is a literal in the bundle, so the font baker finds `text-*`
sizes automatically; they no longer need to be listed in `assets.config.js`.
## Value forms
| Form | Example | Notes |
| --- | --- | --- |
| Theme key | `p-4`, `rounded-lg`, `text-xl` | From the scales below or `tailwind.config.js` |
| Fraction | `w-1/2`, `basis-2/3`, `-left-1/4` | Percent; only where marked **%** |
| `full` | `w-full`, `inset-full` | `100%`; only where marked **%** |
| `screen` | `w-screen`, `max-h-screen` | Render width/height; only where marked **screen** |
| Arbitrary length | `p-[18px]`, `w-[12.5rem]`, `top-[10%]` | `px`, `rem`, or `0`; `%` only where marked **%**. No `em`, `vw`, `vh` |
| Arbitrary number | `opacity-[.35]`, `z-[5]`, `scale-[1.2]` | Unitless |
| Negative | `-mt-2`, `-translate-x-4`, `-rotate-6` | Only where marked **neg** |
| Color | `bg-sky-500`, `bg-[#0af]`, `bg-black/50` | See [Colors](#colors) |
In arbitrary values `_` stands for a space: `bg-[rgb(14_165_233)]`.
### Spacing scale
`0`=0, `px`=1, `0.5`=2, `1`=4, `1.5`=6, `2`=8, `2.5`=10, `3`=12, `3.5`=14,
`4`=16, `5`=20, `6`=24, `7`=28, `8`=32, `9`=36, `10`=40, `11`=44, `12`=48,
`14`=56, `16`=64, `20`=80, `24`=96, `28`=112, `32`=128, `36`=144, `40`=160,
`44`=176, `48`=192, `52`=208, `56`=224, `60`=240, `64`=256, `72`=288, `80`=320,
`96`=384 (logical px).
## Utility reference
### Layout and flexbox
| Class | Style |
| --- | --- |
| `flex` | `display: 'flex'` |
| `hidden` | `display: 'none'` |
| `relative`, `absolute` | `position` |
| `overflow-hidden`, `overflow-visible`, `overflow-scroll` | `overflow` |
| `flex-row`, `flex-row-reverse`, `flex-col`, `flex-col-reverse` | `flexDirection` |
| `flex-wrap`, `flex-wrap-reverse`, `flex-nowrap` | `flexWrap` |
| `flex-`, `flex-[]` | `flex: n` (for example `flex-1`) |
| `flex-auto` | `flexGrow: 1, flexShrink: 1` |
| `flex-initial` | `flexGrow: 0, flexShrink: 1` |
| `flex-none` | `flexGrow: 0, flexShrink: 0` |
| `grow`, `grow-`, `grow-[]` (also `flex-grow-*`) | `flexGrow` (bare = 1) |
| `shrink`, `shrink-`, `shrink-[]` (also `flex-shrink-*`) | `flexShrink` (bare = 1) |
| `basis-*` | `flexBasis`; spacing, **%**, arbitrary |
| `justify-start`, `-end`, `-center`, `-between`, `-around`, `-evenly` | `justifyContent` |
| `items-start`, `-end`, `-center`, `-stretch` | `alignItems` |
| `self-auto`, `-start`, `-end`, `-center`, `-stretch` | `alignSelf` |
| `content-start`, `-end`, `-center`, `-stretch`, `-between`, `-around` | `alignContent` |
| `gap-*`, `gap-x-*`, `gap-y-*` | `gap`, `columnGap`, `rowGap`; spacing, arbitrary px/rem |
| `aspect-square`, `aspect-video`, `aspect-[4/3]` | `aspectRatio` (1, 16/9, w/h) |
| `z-0`, `z-10` … `z-50`, `z-[]` | `zIndex`; **neg** |
| `pointer-events-none`, `-auto`, `-box-none`, `-box-only` | `pointerEvents` (`box-*` are extensions) |
### Spacing
| Prefix | Style keys | Values |
| --- | --- | --- |
| `p`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `ps`, `pe` | `padding`, `paddingHorizontal`, `paddingVertical`, `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft`, `paddingLeft`, `paddingRight` | spacing, arbitrary px/rem |
| `m`, `mx`, `my`, `mt`, `mr`, `mb`, `ml`, `ms`, `me` | `margin…` equivalents | spacing, arbitrary px/rem; **neg** |
`ps`/`ms` map to left and `pe`/`me` to right (LTR only). Percentages and
`m-auto` are rejected.
### Sizing
| Prefix | Style keys | Values |
| --- | --- | --- |
| `w` | `width` | spacing, **%**, **screen**, arbitrary |
| `h` | `height` | spacing, **%**, **screen**, arbitrary |
| `size` | `width` and `height` | spacing, **%**, arbitrary |
| `min-w`, `min-h` | `minWidth`, `minHeight` | spacing, **screen**, arbitrary px/rem |
| `max-w` | `maxWidth` | `xs`=320, `sm`=384, `md`=448, `lg`=512, `xl`=576, `2xl`=672, `3xl`=768, `4xl`=896, `5xl`=1024, `6xl`=1152, `7xl`=1280; spacing, **screen**, arbitrary px/rem |
| `max-h` | `maxHeight` | spacing, **screen**, arbitrary px/rem |
### Position
| Prefix | Style keys | Values |
| --- | --- | --- |
| `inset` | `top`, `right`, `bottom`, `left` | spacing, **%**, arbitrary; **neg** |
| `inset-x`, `inset-y` | `left`+`right`, `top`+`bottom` | same |
| `top`, `right`, `bottom`, `left` | one edge | same |
| `start`, `end` | `left`, `right` (LTR only) | same |
### Colors
Color utilities: `bg-*` (`backgroundColor`), `text-*` (`color`),
`border-*`, `border-{x,y,t,r,b,l}-*` (border colors), `tint-*` (`tintColor`,
for `Image`), `caret-*` (`cursorColor`, the text input cursor).
Accepted colors:
- The Tailwind v3.4.17 palette: `transparent`, `black`, `white`, and `slate`,
`gray`, `zinc`, `neutral`, `stone`, `red`, `orange`, `amber`, `yellow`,
`lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`,
`purple`, `fuchsia`, `pink`, `rose`, each with shades `50`, `100`–`900`, `950`.
`current` and `inherit` are not available.
- Theme colors from `tailwind.config.js`, including nested `DEFAULT` entries.
- Arbitrary `[#rgb]`, `[#rgba]`, `[#rrggbb]`, `[#rrggbbaa]`, `[rgb(…)]`,
`[rgba(…)]`, or an engine color name (`transparent`, `black`, `white`, `red`,
`green`, `blue`, `gray`, `grey`, `yellow`, `cyan`, `magenta`, `orange`).
`hsl()` and other CSS color syntaxes are rejected. Engine color names other
than `transparent` do not accept an opacity modifier.
An opacity modifier is accepted on every color utility: `/0`–`/100` in steps of
5, `/[0.35]`, or `/[35%]`. The result is emitted as `#rrggbbaa`, multiplied
with any alpha the color already has. `bg-opacity-*` and similar are rejected.
### Gradients
`bg-gradient-to-{t,tr,r,br,b,bl,l,tl}` with `from-*`, optional `via-*`, and
`to-*` colors compile to a `backgroundGradient` style, as in Tailwind v3.3+:
```jsx
```
- Stops sit at 0%, 50% (`via-*`), and 100% unless positioned with
`from-10%`, `via-35%`, `to-75%` (0%–100% in steps of 5, extended through
`theme.gradientColorStopPositions`) or an arbitrary `from-[12.5%]`.
Pixel positions are rejected.
- Without `to-*` the gradient fades to a transparent `from` color. A gradient
needs `from-*` and a `bg-gradient-to-*` direction; the build fails otherwise.
- Colors take the usual palette, arbitrary values, and opacity modifiers.
Stops are interpolated premultiplied, as browsers do, so `to-transparent`
fades without a dark fringe.
- Corner directions (`bg-gradient-to-br`) follow the element's aspect ratio,
as CSS `to bottom right` does.
- The gradient replaces `backgroundColor`, is clipped to `rounded-*`, and is
drawn with an ordered dither, so long dark ramps show no 8-bit bands.
- Variants compose with the static parts: `focused:from-sky-500` keeps the
base direction and other stops.
- At most four stops. `bg-gradient-radial`, conic gradients, and `bg-none` are
not available as classes; a radial gradient is available through
`style={{backgroundGradient: {type: 'radial', stops: [...]}}}`.
### Typography
| Class | Style |
| --- | --- |
| `text-xs` … `text-9xl` | `fontSize` and `lineHeight` from the scale below |
| `text-/` | Size with a line height from the `leading` scale or `[…]`, e.g. `text-lg/7`, `text-xl/[1.2]` |
| `text-[]` | `fontSize` only |
| `font-thin`, `-extralight`, `-light`, `-normal`, `-medium`, `-semibold`, `-bold`, `-extrabold`, `-black`, `font-[]` | `fontWeight` 100–900 |
| `font-`, `font-[]` | `fontFamily` from `theme.fontFamily` |
| `leading-*` | `lineHeight` |
| `tracking-*` | `letterSpacing` |
| `text-left`, `-center`, `-right`, `-start`, `-end` | `textAlign` (`start`/`end` follow the text direction) |
| `italic`, `not-italic` | `fontStyle` |
| `underline`, `line-through`, `no-underline` | `textDecorationLine` |
| `truncate` | `numberOfLines: 1, ellipsizeMode: 'tail'` |
| `line-clamp-1` … `line-clamp-6`, `line-clamp-[]` | `numberOfLines: n, ellipsizeMode: 'tail'` |
| `line-clamp-none` | `numberOfLines: 0` |
| `text-ellipsis`, `text-clip` | `ellipsizeMode` `tail` / `clip` |
A `Text` without a fixed height is as tall as the lines it wraps into at the
width it gets, at most `numberOfLines` (none means unlimited): a short title
with `line-clamp-3` takes one line, not three. In a row, a `flex-1` text wraps
at the width left to it and the row grows to hold it. Give the element a height
(`h-*`, `min-h-*`) where a fixed block is wanted, such as aligned card titles.
Font sizes (size / line height, logical px): `xs` 12/16, `sm` 14/20, `base`
16/24, `lg` 18/28, `xl` 20/28, `2xl` 24/32, `3xl` 30/36, `4xl` 36/40, and
`5xl` 48, `6xl` 60, `7xl` 72, `8xl` 96, `9xl` 128 with line height 1×.
Line heights: `leading-3` … `leading-10` are fixed (12–40px); `none` 1,
`tight` 1.25, `snug` 1.375, `normal` 1.5, `relaxed` 1.625, `loose` 2 are
multiples of the font size. In `leading-[…]`, `[1.3]` is relative and `[30px]`
fixed: unitless values are multiples, values with a unit are fixed, as in CSS.
A later `leading-*` overrides the line height that `text-*` sets.
Letter spacing: `tracking-tighter` -0.05em, `tight` -0.025em, `normal` 0,
`wide` 0.025em, `wider` 0.05em, `widest` 0.1em, `tracking-[0.1em]` (relative),
`tracking-[2px]` or `[0.1rem]` (fixed).
Relative line heights and letter spacing need a `text-*` size on the same
element (in the same `className`, or the static part of it for conditional and
variant classes); otherwise the build fails. The engine renders regular and bold
only: `fontWeight` 600 and above is bold, everything else regular.
`font-sans`, `font-serif`, and `font-mono` fail until mapped in the config.
### Borders and radius
| Class | Style |
| --- | --- |
| `border`, `border-0`, `border-2`, `border-4`, `border-8`, `border-[]` | `borderWidth` (bare = 1) |
| `border-x-*`, `border-y-*`, `border-t-*`, `border-r-*`, `border-b-*`, `border-l-*` | Per-edge width or color, same values |
| `border-` | `borderColor` |
| `border-solid`, `border-dashed`, `border-dotted` | `borderStyle` |
| `rounded`, `rounded-{none,sm,md,lg,xl,2xl,3xl,full}`, `rounded-[]` | `borderRadius` (none 0, sm 2, bare 4, md 6, lg 8, xl 12, 2xl 16, 3xl 24, full 9999) |
| `rounded-t-*`, `-r-*`, `-b-*`, `-l-*`, `-tl-*`, `-tr-*`, `-br-*`, `-bl-*` | Corner radii |
Radii round backgrounds, gradients, borders and an `Image`'s bitmap. A rounded
`overflow-hidden` parent clips its children to its rectangle, not its corners:
round the image itself, or give the parent `style={{roundedClip: true}}` to clip
its children to its corners (no class; see [ANIMATION.md](ANIMATION.md#a-light-across-a-card)).
### Effects, images, and transforms
| Class | Style |
| --- | --- |
| `opacity-0` … `opacity-100` (steps of 5), `opacity-[<0–1>]` | `opacity` |
| `object-contain`, `-cover`, `-fill`, `-none` | `resizeMode` `contain`, `cover`, `stretch`, `center` |
| `tint-` | `tintColor` (extension) |
| `scale-{0,50,75,90,95,100,105,110,125,150}`, `scale-[]` | `transform: [{scale}]`; **neg** |
| `scale-x-*`, `scale-y-*` | `scaleX`, `scaleY`; **neg** |
| `rotate-{0,1,2,3,6,12,45,90,180}`, `rotate-[deg]`, `rotate-[rad]` | `rotate`; **neg** |
| `translate-x-*`, `translate-y-*` | `translateX`, `translateY`; spacing, arbitrary px/rem; **neg** |
| `origin-center`, `-top`, `-top-right`, `-right`, `-bottom-right`, `-bottom`, `-bottom-left`, `-left`, `-top-left` | `transformOrigin` |
| `transform-none` | `transform: []` |
Transform classes compose into one `transform` array in the order translate,
rotate, scale. A variant such as `focused:scale-105` keeps the element's base
`rotate-*` or `translate-*`. Percentage translations are rejected.
`Svg`, `Path`, `Circle`, `Rect`, `Line`, and `G` are exported from
`@ps5-react/core` for icons and other vector shapes; they take the Embedded
React SVG props (`d`, `stroke`, `strokeWidth`, `fill`) rather than classes. Up
to 128 `