# LAYR documentation # What is LAYR Source: https://layr.dynshift.com/docs/introduction # What is LAYR LAYR (**L**ayout **A**uthoritative **Y**et **R**esponsive) is a language for the layout of web pages. You write what the page *is*, objects inside objects, measured in your design's numbers. The compiler turns that into static CSS and a React component, and checks it before anything runs. Here is a whole LAYR page. The result is live, and the code under it is editable: change something and press **Run** (or Ctrl S). ```layr Page( .name(Profile) .route('/') var int likes = 0 Scaffold( .config(color: canvas) .body( Column( .config(w: fill, padding: all(32), xAlign: mid) Row( .config( .border(color: line, width: 1) color: panel cornerRadius: 16 gap: 16 padding: all(20) yAlign: mid ) Container(.config(size: 56, color: accent, cornerRadius: 999)) Column( .config(gap: 2) Text(.config(size: 18, weight: semibold) .obj('Ada Lovelace')) Text( .config(color: muted) .obj('Wrote the first published program, in 1843') ) Text( .config(size: 13, color: faint) .obj('$likes people like this') ) ) Button(.preset(default) .config(label: 'Like') .fnc { likes++ }) ) ) ) ) ) ``` ## How to read it Read it top to bottom; the code has the shape of the page. 1. **`Page(...)`** is a page. `.name(Profile)` names it, `.route('/')` is its address. 2. **`var int likes = 0`** is state: a number the page remembers. Anything that reads it updates when it changes. 3. **`Scaffold(...)`** is the screen. `.body(...)` is one of its **slots**, the place the page's content goes. 4. **`Column` and `Row`** stack their objects down and across. Everything inside the `Row` sits side by side: a circle, three lines of text, a button. 5. **`.config(...)`** sets an object's features. `size: 56` is 56 **design pixels**: a pixel of your design, which LAYR scales to every screen ([Design Scale](/docs/design-scale)). 6. **`color: panel`** is a colour from the theme. The examples on this site use its theme, so they follow the one you pick at the top of the page. 7. **`.fnc { likes++ }`** runs when the button is pressed. Round brackets hold LAYR; curly braces hold TypeScript. > **Try it** > - Change `gap: 16` to `gap: 40` and run: the space between the three parts grows. > - Change `cornerRadius: 999` to `cornerRadius: 8`: the circle becomes a rounded square. > - Press **m** in the result bar (a 390px phone), or drag the corner narrower. When the text would be squeezed, the row stacks into a column by itself. Nothing in the code asks for that: it is LAYR's [adaptation](/docs/layout#when-space-runs-out). ## Why a language Web layout is usually a negotiation between CSS units, breakpoints, framework conventions and component libraries. LAYR makes the layout the program: - **The code's shape is the UI's shape.** Objects nest the way they appear. Logic stays in `.config`, `.fnc` and `{ }` blocks. - **Design units, not CSS units.** Numbers come from your design frame, and [Design Scale](/docs/design-scale) fits them to every screen without media queries. - **Deterministic layout.** Every feature resolves in one documented order, and overflow adapts by fixed rules that `layr analyze` can explain. - **Reachable from anywhere.** [Export, Extract and Inject](/docs/export-extract-inject) let any part of an app read and change any object, as ordered layers the compiler checks. - **One canonical form.** `layr format` writes every file the same way, so code reads the same everywhere and AI agents write it correctly. ## What you get | Piece | What it does | |---|---| | The language | `.layr` files: Pages, Widgets, Functions, state, layout | | The compiler | Checks names, types, layout and Export/Extract/Inject before anything runs; emits CSS and React | | The runtime | Signals, the injection cascade, measurement, animation, routing | | The CLI | `layr create`, `dev`, `build`, `format`, `analyze`, `test`, `add` and more | | Tooling | A VS Code extension, a language server, an MCP server and the LAYR Skill for AI agents | ## How it fits with React LAYR compiles to React components, so a LAYR app can use any React package (even as JSX, right in the tree) and a React app can import `.layr` files. React is the engine underneath, not the way you write LAYR. See [React interop](/docs/react). Next: [Quick start](/docs/quick-start), or [the syntax](/docs/syntax) if you want to read more LAYR first. --- # Quick start Source: https://layr.dynshift.com/docs/quick-start # Quick start You need Node 20 or newer. ```sh npx @dynshift/layr create my-app cd my-app npm install npx layr dev ``` Open the address it prints. Edit `src/pages/index.layr` and save; the page reloads. ## The project ```text my-app/ layr.yaml project settings (name, addons, access rules) index.html the HTML shell src/ app.layr design frames and theme pages/index.layr the home page (route /) ``` Every file in `src/pages/` is a page. Its route comes from its path (`src/pages/about.layr` is `/about`) unless it sets `.route(...)`. ## Check, format, build ```sh npx layr analyze # every diagnostic, with explanations npx layr format # canonical form npx layr build # static site in dist/, every route prerendered npx layr preview # serve dist/ ``` `layr build` output is plain static files. Host it anywhere: GitHub Pages, Cloudflare Pages, Netlify, any web server. See [Deploy](/docs/deploy). ## Editor Install the **LAYR** extension for VS Code (publisher `dynshift`) for diagnostics, completion, hover docs and format on save. ## AI agents ```sh npx layr skills install ``` This gives Claude Code (and other agents that read `AGENTS.md`) the LAYR Skill, so they write LAYR instead of guessing from React or Flutter. See [AI agents](/docs/ai). --- # Syntax Source: https://layr.dynshift.com/docs/syntax # Syntax A `.layr` file is a list of **items**. An object is a name followed by its items in round brackets: ```layr Container( .config(w: 220, color: panel, cornerRadius: 12, padding: all(16)) .obj(Text('Hello')) ) ``` ## The one rule **`( )` holds LAYR structure. `{ }` holds TypeScript.** Objects, config and children go in round brackets. Code that *does* something goes in curly braces: here, what a click does. ```layr Page( .name(Counter) .route('/') var int count = 0 Scaffold( .config(color: canvas) .body( Row( .config(gap: 12, padding: all(24), yAlign: mid) Button(.preset(default) .config(label: 'Add one') .fnc { count++ }) Text('Clicked $count times') ) ) ) ) ``` > **Try it** > - Change `count++` to `count += 10`. Anything TypeScript accepts works inside the braces. > - Change the text to `'Clicked ${count * 2} halves'`: `$name` inserts a value, `${...}` inserts an expression. ## Items Items are separated by line breaks or commas. Two items on one line need a comma, except modifiers, which may follow each other with a space. | Item | Looks like | Example | |---|---|---| | Modifier | `.name(...)` or `.name { ts }` | `.config(w: 200)`, `.fnc { count++ }` | | Prop | `key: value` | `w: 200` | | Declaration | `[export] [!mut] var\|const\|bind type name = value` | `var int count = 0` | | Object or value | anything else | `Text('Hi')`, `'Hello'` | ## Two forms of the same object The expanded form puts config in `.config(...)` and children in `.obj(...)`. The shorthand puts props and children straight into the call. These two are the same object: ```layr noexec Container(.config(padding: all(12)) .obj(Column(Text('One'), Text('Two')))) Container(padding: all(12), Column(Text('One'), Text('Two'))) ``` `layr format` keeps the shorthand when it fits on one line and expands it otherwise, so a file only ever has one spelling. ## Numbers are design pixels A plain number is a **design pixel**: a pixel of your design at its design frame. [Design Scale](/docs/design-scale) turns it into the right size on every screen. `px` is a real screen pixel that never scales, for things like a hairline. ```layr Column( Container( .config(w: 240, h: 36, color: accent) .obj(Text(.config(color: onAccent) .obj('w: 240 (design px)'))) ) Container( .config(w: 240px, h: 36, color: teal) .obj(Text(.config(color: onAccent) .obj('w: 240px (screen px)'))) ) ) ``` > **Try it** > - Press **m**, **t** and **w** in the result bar. The first bar scales with the frame; the second stays exactly 240 screen pixels. ## Values | Kind | Examples | |---|---| | Lengths | `200` (design px), `200ds`, `12px`, `50%`, `1fr`, `fill`, `hug` | | Time and angles | `300ms`, `1.5s`, `45deg` | | Colours | `#fff`, `#0d0d0d`, `#0d0d0d80`, `0xFF0D0D0D`, `rgb(13, 13, 13)`, `white`, a theme colour such as `accent` | | Colour methods | `#fff.alpha(50%)`, `white.shade(2)`, `accent.tint(1)`, `ink.invert`, `a.mix(b, .3)` | | Insets | `all(20)`, `sym(x: 20, y: 10)`, `only(left: 20, top: 8)` | | Sizes | `(200, 120)` where a size is expected | | Lists | `[1, 2, 3]` | | Strings | `'Hello $name'`, `'Total: ${a + b}'` | | Alignments | `topLeft`, `topMid`, `mid`, `bottomRight`… (short forms such as `tL`, `rB` are accepted) | Colour methods work on theme colours too, so a whole palette can come from one accent: ```layr Row( .config(gap: 8) Container(.config(size: 48, color: accent, cornerRadius: 10)) Container(.config(size: 48, color: accent.alpha(60%), cornerRadius: 10)) Container(.config(size: 48, color: accent.alpha(30%), cornerRadius: 10)) Container(.config(size: 48, color: accent.mix(teal, 50%), cornerRadius: 10)) Container(.config(size: 48, color: teal, cornerRadius: 10)) ) ``` ## Comments ```layr noexec // A line comment /* A block comment */ Text('Hi') // after an item ``` `#` always starts a colour, never a comment. ## Aliases and canonical form LAYR accepts common alternative spellings while you type (`Col`, `Center`, `width`, `pad`, `.exc`, `.opacity()`), reports them as info, and `layr format` rewrites them to the canonical name (`Column`, `Mid`, `w`, `padding`, `.exe`, `.alpha()`). Stored code is always canonical. ## Arrow functions Callbacks are arrow functions written right inside an expression: `(a) => a.done`, or `a => a.done` for one parameter. ```layr Page( .name(Tasks) .route('/') const list tasks = ['Write', 'Format', 'Ship'] Scaffold( .config(color: canvas) .body( Column( .config(gap: 8, padding: all(24)) Each(.of(tasks.filter((t) => t != 'Format')) .as(t) .obj(Text('• $t'))) Text( .config(color: muted) .obj('${tasks.map((t) => t.toUpperCase()).join(", ")}') ) ) ) ) ) ``` > **Try it** > - Change `t != 'Format'` to `t.length > 4`. --- # Objects Source: https://layr.dynshift.com/docs/objects # Objects Everything on screen is an **object**: a widget, its **config** (how it looks and sizes), and its **children** (what is inside it). This page takes those three parts one at a time. ## Config and children `.config(...)` sets the object's features. `.obj(...)` holds the one object inside it. ```layr Container( .config( w: 240 .border(color: line, width: 1) color: panel cornerRadius: 16 padding: all(20) ) .obj(Text(.config(size: 17, weight: semibold) .obj('A card, 240 wide'))) ) ``` Every number is in design pixels. `padding: all(20)` is space on all four sides inside the edge; `.border(...)` is a **group**, several related keys written together. > **Try it** > - Change `all(20)` to `sym(x: 40, y: 8)`: 40 on the left and right, 8 above and below. > - Change `cornerRadius: 16` to `cornerRadius: 999`: fully round ends. > - Delete `w: 240`: the card now *hugs* its text, the default for every object. The formatter keeps config in one order, sizes first (`w`, `h`, `size`, the `min`/`max` keys), then the rest alphabetically, so every file reads the same. Every widget's keys, with types and defaults, are in the [API reference](/api). ## Slots: where children go A widget with several places for children names them. `Scaffold`, the screen, has a `.bar` on top, a `.body` that scrolls, and a `.footer`. Each band below says which slot it is in: ```layr Page( .name(Slots) .route('/') Scaffold( .config(color: canvas) .bar( Row( .config(w: fill, color: sunken, padding: all(14)) Text(.config(weight: semibold) .obj('.bar')) ) ) .body( Column( .config(w: fill, gap: 8, padding: all(20)) Text('.body: the page content') Text( .config(color: muted) .obj('It scrolls when it is taller than the screen.') ) ) ) .footer( Row( .config(w: fill, color: sunken, padding: all(14)) Text(.config(color: muted) .obj('.footer')) ) ) ) ) ``` `.obj(x)` is the default slot for one child and `.objs(a, b)` the list form. Children written straight into the call (`Column(Text('One'), Text('Two'))`) go into the default slot. > **Try it** > - Remove the whole `.footer(...)` line: the body takes its space. > - Add a third `Text('More')` to the body. ## Identity: `.id` `.id(name)` names an object. An id is unique inside its Page or Widget, and it is how other code reaches the object: `Slots.card` from any file, to read or change it with [Export, Extract and Inject](/docs/export-extract-inject). ```layr noexec Container(.id(card) .config(w: 200, color: panel, padding: all(16))) ``` Objects without an id are still reachable by their **lookup path**, one `.` per level (`Slots.scaffold.body.column.text(1)`). Ids survive edits that would change a path. ## Presets: a name for a look A preset is a named bundle of config for one widget. Define it once, use it anywhere: ```layr Preset( .name(pill) .for(Button) .config( padding: sym(x: 16, y: 8) cornerRadius: 999 color: sunken .border(color: lineStrong, width: 1) ) ) Page( .name(Buttons) .route('/') Scaffold( .config(color: canvas) .body( Row( .config(gap: 12, padding: all(24)) Button(.preset(pill) .config(label: 'Save')) Button(.preset(pill) .config(label: 'Share')) Button(.preset(default) .config(label: 'Cancel')) ) ) ) ) ``` Config written on the object wins over its preset: `Button(.preset(pill) .config(color: accent))` keeps the pill shape with an accent fill. `.preset(default)` is LAYR's neutral, accessible look for interactive widgets; core widgets are otherwise unstyled. > **Try it** > - In the `Preset`, change `cornerRadius: 999` to `6`: all three pill buttons change together, `Cancel` does not. ## Per-frame config: `.at` `.at(frame, key: value)` changes config at a [design frame](/docs/design-scale): `m` (phone), `t` (tablet), `w` (desktop). The plain `.config` value is the one for phones. ```layr Container( .config(w: 160, color: accent, padding: all(16)) .at(w, w: 480) .obj( Text( .config(color: onAccent, weight: semibold) .obj('160 on phones, 480 on desktop') ) ) ) ``` Between frames the number is **interpolated**: at a tablet width it is part way between 160 and 480, so nothing jumps. Write `step` for a value that switches at the frame instead: `.at(t, step h: 60)`. > **Try it** > - Press **m**, **t** and **w** in the result bar and watch the width move. > - Add `.at(t, color: violet)`: colours cannot be part way, so they switch at the frame. ## Conditions and lists `If` shows one branch or the other (`.fb(...)` is the fallback). `Each` repeats an object for every item of a list: ```layr Page( .name(Lists) .route('/') var bool showAll = false const list names = ['Ada', 'Grace', 'Linus', 'Margaret'] Scaffold( .config(color: canvas) .body( Column( .config(gap: 10, padding: all(24)) Button( .preset(default) .config(label: showAll ? 'Show fewer' : 'Show everyone') .fnc { showAll = !showAll } ) If( .cnd(showAll) .obj(Each(.of(names) .as(name, i) .obj(Text('${i + 1}. $name')))) .fb( Text(.config(color: muted) .obj('${names.length} people, hidden')) ) ) ) ) ) ) ``` > **Try it** > - Add `'Barbara'` to the list and run: `Each` shows five without any other change. > - Swap the `.obj(...)` and `.fb(...)` contents: the condition now works the other way round. ## Accessibility `Text(.config(type: h1))` renders a real heading. Images need `alt` (or `decorative: true`) and inputs need a `label`: the compiler reports L6001 and L6002 otherwise. `.a11y(label: ..., role: ...)` sets an accessible name and role on any object. --- # Widgets Source: https://layr.dynshift.com/docs/widgets # Widgets A `Widget` is an object you define once and use anywhere. Its **params** become its config keys, so your widgets are used exactly like the core ones. ```layr Widget( .name(Card) .param(req txt title, color tint = accent, len pad = 16) .obj( Container( .config( w: fill .border(color: param.tint, width: 2) color: panel cornerRadius: 14 padding: all(param.pad) ) .obj( Column( .config(gap: 6) Text( .config(size: 17, color: param.tint, weight: semibold) .obj(param.title) ) .obj ) ) ) ) ) Page( .name(Cards) .route('/') Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Card(.config(title: 'Hello') .obj(Text('Any object can go inside.'))) Card(.config(title: 'Tinted, roomier', tint: teal, pad: 28)) ) ) ) ) ``` Read it in two halves. The `Widget` says what a Card *is*: a bordered container with a title, and `.obj` where the caller's content goes. The `Page` *uses* it twice, and each `Card(...)` sets the params the way any object sets config. > **Try it** > - Add `Card(.config(title: 'Third', tint: violet))` under the other two. > - Change `cornerRadius: 14` in the Widget to `0`: every card changes, because there is one definition. > - Remove `title: 'Hello'` from the first card. `title` is `req`uired, so the result reports the error instead of running. ## Params ```text .param( req txt title required: every use must set it color tint = accent with a default num? ratio optional (may be null) !mut len pad = 16 cannot be changed by Export/Extract/Inject ) ``` Read a param as `param.name`. Types: `int num txt bool color paint len size insets align axis time obj list any` (`double`, `string` and `padding` are accepted aliases). ## Forwarding the caller's object `.obj` on its own, where an object is expected, forwards whatever the caller put in the widget's `.obj(...)`. Above, `Text('Any object can go inside.')` lands in the card's column, under the title. ## Layout keys on your widgets Callers can also set layout keys that apply to the widget's root: `w`, `h`, `minW`, `maxW`, `minH`, `maxH`, `margin`, `opacity`, `flex`, `shrink`, `hide` and `cursor`. `Card(.config(title: 'Narrow', maxW: 240))` works without the widget declaring anything. ## Widget state and functions A widget can hold its own state and Functions, and every instance has its own copy: ```layr Widget( .name(Counter) .param(txt label = 'Count') var int n = 0 Function(.name(add) .def { n++ }) .obj(Button(.preset(default) .config(label: '${param.label}: $n') .fnc(add))) ) Page( .name(Counters) .route('/') Scaffold( .config(color: canvas) .body( Row( .config(gap: 12, padding: all(24)) Counter(.config(label: 'Apples')) Counter(.config(label: 'Pears')) ) ) ) ) ``` > **Try it** > - Click each button a few times: the two counts are separate. > - Move `var int n = 0` out of the Widget, to the top of the file. Now both buttons share one count, because file-level state belongs to the whole app ([State](/docs/state)). ## A file with one object A file whose body is one object is an anonymous widget named after the file: `src/widgets/badge.layr` containing `Container(...)` can be imported as `import Badge from '../widgets/badge.layr'`. --- # State Source: https://layr.dynshift.com/docs/state # State State is what a page remembers. Declare it with `var`, and everything that reads it updates when it changes: no subscriptions, no setters. ```layr Page( .name(Profile) .route('/') var txt name = 'Ada' var int visits = 0 bind txt greeting = 'Hello, $name (visit ${visits + 1})' Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Text(.config(size: 24, type: h2) .obj(greeting)) Input( .preset(default) .config(label: 'Name', value: name) .fnc { name = value } ) Button(.preset(default) .config(label: 'Visit') .fnc { visits++ }) ) ) ) ) ``` Three kinds of declaration are at work: | Declaration | What it is | Here | |---|---|---| | `var` | State: change it and everything that reads it updates | `name`, `visits` | | `bind` | Derived: always equal to its expression, never set by hand | `greeting` | | `const` | A constant | | Reading `name` in the `bind`, and `greeting` in the `Text`, is what connects them. Only the objects that read a value re-render when it changes. > **Try it** > - Type in the box: the heading follows each keystroke, through the `bind`. > - Add `Text(.config(color: muted) .obj('${name.length} letters'))` at the end of the column. > - Replace `.obj(greeting)` with `.obj('Hi, $name')`: the same updating text, written in place. A `bind` is a name for an expression you want to reuse. ## Where state lives | Declared in | Lives | |---|---| | A file (outside any Page or Widget) | For the whole app, shared everywhere | | A Page | For the session: kept when you navigate away and back. `.state(reset)` makes it start fresh on each visit | | A Widget | One copy per instance, for as long as the instance is shown | | A Function | For one call | Because page state is kept, other pages can [Extract](/docs/export-extract-inject) it even when that page is not on screen. ## The whole syntax ```text var int count = 0 state const txt title = 'Hello' a constant bind txt label = '$title: $count' derived !mut var int seed = 42 refuses changes from Export/Extract/Inject _draft underscore names stay private to their file ``` ## Types `int num txt bool color paint len size insets align axis time obj list map any`, plus `T?` for values that may be empty. A declaration without a type (`var index = 0`) takes the type of its value. ## `!mut` `!mut` marks something that must never change from outside its owner. The compiler rejects any Export/Extract/Inject write to it (L3101); at runtime such writes are ignored with a warning. --- # Functions and events Source: https://layr.dynshift.com/docs/functions # Functions and events A `Function` is a named action. Write its body as **SAPI steps** (LAYR's own step language) or as **TypeScript**: both compile to the same thing and can be mixed in one file. ```layr Page( .name(Steps) .route('/') var int count = 0 // SAPI steps: count up to 9, then wrap to 0 Function( .name(step) .param(ref int n) .def(.if(.cnd(n >= 9) .exe(n = 0)) .fb(.exe(n++))) ) // The same idea in TypeScript Function(.name(reset) .param(ref int n) .def { n = 0 }) Scaffold( .config(color: canvas) .body( Row( .config(gap: 12, padding: all(24), yAlign: mid) Text(.config(size: 32, weight: bold) .obj('$count')) Button(.preset(default) .config(label: 'Step') .fnc(step(count))) Button(.preset(default) .config(label: 'Reset') .fnc(reset(count))) ) ) ) ) ``` `.fnc(step(count))` calls the Function when the button is pressed. `ref int n` means the Function receives the caller's `count` itself, so assigning `n` changes `count`. Without `ref`, a param is a plain value. > **Try it** > - Press **Step** ten times: it wraps from 9 to 0. > - Change `n >= 9` to `n >= 3`. > - Rewrite `step` in TypeScript: `.def { n = n >= 9 ? 0 : n + 1 }`. Same behaviour. ## SAPI steps | Step | Meaning | |---|---| | `.if(.cnd(test) steps…)` | Adjacent `.if`s form a chain: the first whose test is true runs | | `.fb(steps…)` | Runs when no `.if` in the chain matched | | `.exe(statement)` or `.exe { ts }` | Runs a statement | | `.wait(100ms)` | Pauses the action | | `.loop(steps…)` | Repeats while the object that started it is shown; add `.times(n)` or `.while(test)` | | `.call(f(args))` | Calls a Function | | `.go(Page, key: value)` | Navigates | The analyzer reports conditions in a chain that can never run. ## TypeScript bodies Inside `{ }` you write TypeScript. LAYR state reads and writes like plain variables, and LAYR literals work too: `200ds`, `12px`, `300ms`, `#ff0000`, and `await 100ms` to wait. ```layr Page( .name(Pulse) .route('/') var bool on = false Function( .name(blink) .def { for (let i = 0; i < 6; i++) { on = !on await 200ms } } ) Scaffold( .config(color: canvas) .body( Row( .config(gap: 16, padding: all(24), yAlign: mid) Container( .config(size: 40, color: on ? accent : sunken, cornerRadius: 999) ) Button(.preset(default) .config(label: 'Blink three times') .fnc(blink)) ) ) ) ) ``` > **Try it** > - Change `200ms` to `60ms`. > - Wrap the circle in `Animate(...)` (see [Animation](/docs/animation)) so each change fades instead of snapping. Writing another object's feature (`card.w = 360`) is an imperative [Inject](/docs/export-extract-inject). ## Events `.fnc(action)` runs the widget's **primary action**: a Button or Link press, an Input, Toggle, Select or Slider change (the new value is `value`), a Form submit. `.on(event: action)` handles the rest: ```layr Page( .name(Hover) .route('/') var bool over = false var int taps = 0 Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Container( .config( w: 220 .border(color: line, width: 1) color: over ? accent : panel cornerRadius: 14 padding: all(20) ) .on( hover: { over = true } hoverEnd: { over = false } tap: { taps++ } ) .obj( Text( .config(color: over ? onAccent : ink) .obj(over ? 'Pointer inside' : 'Point at me') ) ) ) Text(.config(color: muted) .obj('Tapped $taps times')) ) ) ) ) ``` Events: `tap`, `press`, `hover`, `hoverEnd`, `focus`, `blur`, `key`, `mount`, `unmount`, plus `change`/`submit` on inputs and `close` on overlays. Any object with `.fnc` becomes keyboard accessible (Enter and Space). Actions stop cleanly when the object that started them goes away: waits and loops end instead of running in the background. --- # Pages and the app Source: https://layr.dynshift.com/docs/pages # Pages and the app An app is a set of **Pages**, each at its own address, plus one **App** file that configures all of them. ## Pages and navigation Here are two pages that link to each other. The result is a small app: click the links. ```layr Page( .name(Home) .route('/') Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Text(.config(size: 28, type: h1) .obj('Home')) Link(.config(label: 'Read about us', to: About)) Button( .preset(default) .config(label: 'Open post 3') .fnc(.go(Post, id: 3)) ) ) ) ) ) Page( .name(About) .route('/about') Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Text(.config(size: 28, type: h1) .obj('About')) Link(.config(label: '← Home', to: Home)) ) ) ) ) Page( .name(Post) .route('/posts/:id') .meta(title: 'A post') .load { return { title: 'Post number ' + route.id } } Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Text(.config(size: 28, type: h1) .obj('${data?.title ?? "Loading"}')) Link(.config(label: '← Home', to: Home)) ) ) ) ) ``` - **`Link(.config(to: About))`** navigates inside the app without reloading. `to:` takes a Page, so a renamed or deleted page is a compile error, never a dead link. - **`.go(Post, id: 3)`** navigates from an action, filling the route's `:id`. - **`route`** holds the route params (`route.id`) and query values. - **`.load { }`** runs when the page is visited; what it returns is `data`, and the page renders again when it arrives. - **`.meta(title: ...)`** sets the document title; `layr build` writes it into each prerendered page. > **Try it** > - Change `.go(Post, id: 3)` to `id: 42`, run, and press the button. > - Add a third Link on Home: `Link(.config(label: 'Post 7', href: '/posts/7'))`. `href` is a plain address, for links outside the app's pages. Routes come from file paths unless a page sets `.route(...)`: `src/pages/index.layr` is `/`, `src/pages/about.layr` is `/about`, `src/pages/docs/intro.layr` is `/docs/intro`, and `src/pages/posts/[id].layr` is `/posts/:id`. ## Scaffold `Scaffold` is the page's viewport box: at least the screen's height, safe areas respected. Its slots are `.bar(...)` (a sticky header), `.body(...)` and `.footer(...)`, shown on the [Objects](/docs/objects#slots-where-children-go) page. `scroll: none` makes a fixed, app-like screen that uses the `contain` [Design Scale fit](/docs/design-scale). ## The App `src/app.layr` configures the whole app: its [Design Scale](/docs/design-scale) frames, its theme and its React providers. ```layr App( .scale( DesignScale( .m(w: 390, h: 844) .t(w: 834, h: 1194) .w(w: 1440, h: 900) .uw(w: 2560, h: 1080) ) ) .theme( Theme( .colors(canvas: #f4efe7, panel: #fffcf7, ink: #1c1917, accent: #c93f12) .dark(canvas: #121110, panel: #1a1816, ink: #f3ede6, accent: #ff6a3d) .font(body: 'Inter') ) ) ) ``` Theme colours become names you can use anywhere (`color: accent`, `ink.alpha(70%)`), and CSS variables (`--layr-color-accent`) for React components. `.dark(...)` gives the dark-mode values; they follow the reader's system setting. `.font(body: ...)` sets the app font. The examples on this site all run in this site's own theme, which is why they can say `color: panel` and change with the theme you pick at the top of the page. --- # Layout and adaptation Source: https://layr.dynshift.com/docs/layout # Layout and adaptation Layout in LAYR comes down to three questions for every object: how wide and tall is it, how are its children arranged, and what happens when there is not enough room. This page answers them in that order. ## Sizing: fixed, hug, fill Every width and height is one of three kinds: | Size | Means | |---|---| | a length: `200`, `50%`, `12px` | **fixed**: exactly that | | `hug` (the default) | fit the content | | `fill` | share the space the parent has left, by `flex` weight | Each box below says which it is: ```layr Row( .config(w: fill, gap: 12) Container( .config(w: 200, h: 72, color: ember, cornerRadius: 10, padding: all(12)) .obj(Text(.config(color: onAccent, weight: semibold) .obj('w: 200'))) ) Container( .config(w: fill, h: 72, color: violet, cornerRadius: 10, padding: all(12)) .obj(Text(.config(color: onAccent, weight: semibold) .obj('w: fill'))) ) Container( .config( w: fill h: 72 color: teal cornerRadius: 10 flex: 2 padding: all(12) ) .obj( Text(.config(color: onAccent, weight: semibold) .obj('w: fill, flex: 2')) ) ) ) ``` The first box takes its 200. The other two share what is left, and `flex: 2` takes two shares to the other's one. > **Try it** > - Drag the result's corner: only the `fill` boxes change width, until there is no room left and the row stacks (see [When space runs out](#when-space-runs-out)). > - Change `flex: 2` to `flex: 1`: the two fill boxes become equal. > - Change the first box's `w: 200` to `w: hug`: it shrinks to fit its label. These are the same three modes as Figma's auto layout, so a design translates directly. Add `minW`, `maxW`, `minH`, `maxH` and `aspect` when you need limits. `fill` needs space to share: `fill` along a scroll area's axis is a compile error (L2001). ## Arranging: the primitives | Widget | Lays out | |---|---| | `Row`, `Column` | objects along an axis; `xAlign` and `yAlign` mean horizontal and vertical in both | | `Container` | one object, with size, paint, border, corners, shadow, padding; `objAlign` places the object | | `Stack` | objects on top of each other; `Order(.posOrder(n))` sets layers | | `Position` | offsets an object in a Stack (`x`, `y`, or `top`/`right`/`bottom`/`left`, negatives allowed) | | `Mid`, `Align` | fill the parent and place the object in the middle or at an alignment | | `Expand` | makes its object fill the remaining space | | `Gap` | fixed space: `Gap(24)` | | `Wrap`, `Grid` | wrapping rows; grids with fixed columns or a minimum item width | | `Scroll`, `Aspect`, `SafeArea` | scrolling, aspect ratio, device safe areas | `xAlign` and `yAlign` take `start`, `mid`, `end`, `between`, `around`, `evenly` or `stretch`. A `Stack` puts objects on top of each other, and `Position` pins one to an edge. A notification badge on a card: ```layr Stack( Container( .config( w: 220 .border(color: line, width: 1) color: panel cornerRadius: 14 padding: all(20) ) .obj( Column( .config(gap: 4) Text(.config(weight: semibold) .obj('Inbox')) Text(.config(color: muted) .obj('3 unread messages')) ) ) ) Position( .config(right: -8, top: -8) .obj( Container( .config(size: 26, color: accent, cornerRadius: 999, objAlign: mid) .obj(Text(.config(size: 13, color: onAccent, weight: bold) .obj('3'))) ) ) ) ) ``` > **Try it** > - Change `right: -8, top: -8` to `bottom: 12, right: 12`: the badge moves inside the card. > - Change the Row of boxes above to a `Column`: every `w: fill` now fills the width, one under another. ## When space runs out Resize a page and something has to give. By default (`overflow: auto`) a Row adapts in a fixed order: 1. **Shrink.** Flexible content shrinks toward its minimum, and text wraps. `shrink: 0` on a child keeps it whole. 2. Then, by what the row holds: - **Only `hug` children** (chips, buttons, links): the row **wraps** onto new lines. - **A `fill` or `Expand` child** (content panes): the row **stacks** into a column once a pane would be squeezed, and becomes a row again when there is room. 3. **Clip, with a warning** (L2101), only when nothing can adapt, such as a fixed child wider than a fixed parent. The two rows below show both. Press **m** in the result bar, or drag the corner: ```layr Column( .config(w: fill, gap: 20) Text(.config(color: muted) .obj('Only hug children: they wrap')) Row( .config(gap: 8) Container( .config(color: sunken, cornerRadius: 999, padding: sym(x: 14, y: 8)) .obj(Text('Layout')) ) Container( .config(color: sunken, cornerRadius: 999, padding: sym(x: 14, y: 8)) .obj(Text('Design Scale')) ) Container( .config(color: sunken, cornerRadius: 999, padding: sym(x: 14, y: 8)) .obj(Text('Animation')) ) Container( .config(color: sunken, cornerRadius: 999, padding: sym(x: 14, y: 8)) .obj(Text('Export, Extract, Inject')) ) ) Text(.config(color: muted) .obj('A fill child: the row stacks')) Row( .config(w: fill, gap: 12) Container( .config(w: 200, h: 90, color: ember, cornerRadius: 10, padding: all(12)) .obj(Text(.config(color: onAccent, weight: semibold) .obj('w: 200'))) ) Container( .config(w: fill, h: 90, color: violet, cornerRadius: 10, padding: all(12)) .obj( Text( .config(color: onAccent, weight: semibold) .obj('w: fill: a pane that must not be squeezed') ) ) ) ) ) ``` Nothing in that code mentions a screen size. `layr analyze --frames` tells you where each row adapts, and why. Choose the behaviour yourself with `overflow: wrap | stack | scroll | clip | shrink | warn | error`, per frame with `.at(m, overflow: scroll)`. Or offer whole alternatives with `Adapt`, which shows the first one that fits: ```layr Adapt( Row( .config(gap: 24) Text('Home') Text('Docs') Text('Library') Text('Blog') Text('Community') Text('Changelog') ) Text(.config(weight: semibold) .obj('☰ Menu')) ) ``` > **Try it** > - Press **m**: six links do not fit, so `Adapt` shows the menu instead. > - Add `.config(overflow: scroll)` to the chips row: it scrolls sideways instead of wrapping. A `Column` with a fixed height scrolls when its content is taller. --- # Design Scale Source: https://layr.dynshift.com/docs/design-scale # Design Scale Write the numbers from your design. `200` means 200 pixels **of your design frame**, and LAYR fits it to the screen: no media queries, no `rem`, `vw` or `clamp()` arithmetic. ## One number, every screen The bar below is `w: 240`: 240 pixels of the design. It reports its real width in screen pixels: ```layr Page( .name(Scale) .route('/') Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Container( .id(bar) .config( w: 240 h: 44 color: accent cornerRadius: 10 objAlign: midLeft padding: sym(x: 14) ) .obj(Text(.config(color: onAccent, weight: semibold) .obj('w: 240'))) ) Text( .config(color: muted) .obj('On this screen: ${Math.round(Scale.bar.size?.w ?? 0)} px wide') ) ) ) ) ) ``` `Scale.bar.size` is the bar's **rendered** size, measured after layout (see [Extract](/docs/export-extract-inject)). It is empty for the first moment, before anything is measured, which is what `?.` and `?? 0` cover. > **Try it** > - Press **m**, **t** and **w** in the result bar. Each is exactly its frame's design width (390, 834, 1440), so the bar is exactly 240 screen pixels at each. > - Now drag the corner slowly from narrow to wide and watch the number. Between frames the bar follows the screen, but never below 0.9 or above 1.15 times its design size (216 to 276 here). At 600 the tablet frame takes over, and from there the bar is measured against the 834-wide tablet design instead. ## Frames A frame is a design size and the screen width where it starts. The defaults: | Frame | Design size | From | |---|---|---| | `m` | 390 × 844 | 0 | | `t` | 834 × 1194 | 600 px | | `w` | 1440 × 900 | 1024 px | | `uw` | 2560 × 1080 | 1920 px | Set yours in `src/app.layr` to match your design files: ```layr App( .scale( DesignScale( .m(w: 375, h: 812) .w(w: 1280, h: 800) .config(min: 0.9, max: 1.2) ) ) ) ``` `.frame(name, w:, h:, from:)` adds a custom frame. `.config(min:, max:)` sets how far a value may scale inside a frame: here between 0.9 and 1.2 times its design size. ## Numbers flow, structure steps - **One value** (`w: 240`) scales with the screen inside the active frame, within the frame's limits. Phones and tablets use the short side of the screen, so rotating a device does not rescale everything. - **A value given at several frames** is **interpolated** between the frames' design widths, so it moves smoothly instead of jumping at a breakpoint. - **Structure** (which widget, direction, overflow mode, visibility) switches at frame boundaries. ```layr Column( .config(gap: 8) Text( .config(size: 32, weight: bold) .at(w, size: 64) .obj('32 on phones, 64 on desktop') ) Text( .config(color: muted) .at(t, step hide: true) .obj('This line is only on phones.') ) ) ``` > **Try it** > - Drag the corner from narrow to wide: the heading grows smoothly between 32 and 64. > - Watch the second line: it is structure, so it switches off at the `t` frame instead of fading. ## Fonts and accessibility Font sizes scale more gently than lengths and keep a `rem` part, so browser zoom and the reader's font-size setting always work. The analyzer warns if a font's largest size is more than 2.5 times its smallest (L6101), which keeps text zoomable to 200% (WCAG 1.4.4). ## Screen pixels and percentages Use `px` for things that must not scale, such as `borderWidth: 1px`, and `%` for sizes relative to the parent. See [Syntax: numbers are design pixels](/docs/syntax#numbers-are-design-pixels) for the two side by side. ## Components that scale with their space Wrap an object in `DesignScale(...)` to scale it against the space its parent gives it rather than the screen. A card designed 400 wide renders at half size in a 200-wide slot: ```layr Row( .config(gap: 16, yAlign: start) Container( .config(w: 200) .obj( DesignScale( .frame(card, w: 400, h: 300, from: 0) .config(min: 0.25, max: 4) .obj( Container( .config( w: 400 .border(color: line, width: 2) color: panel cornerRadius: 20 padding: all(24) ) .obj(Text(.config(size: 28, weight: bold) .obj('Designed at 400'))) ) ) ) ) ) Container( .config(w: 320) .obj( DesignScale( .frame(card, w: 400, h: 300, from: 0) .config(min: 0.25, max: 4) .obj( Container( .config( w: 400 .border(color: line, width: 2) color: panel cornerRadius: 20 padding: all(24) ) .obj(Text(.config(size: 28, weight: bold) .obj('Designed at 400'))) ) ) ) ) ) ) ``` Both cards are the same 400-wide design, given 200 and 320 of space: each scales to its slot. (Text scales a little more gently than lengths, so it stays readable in the small one.) ## From a design tool Frames are your design frames, a design pixel is the design tool's pixel, and hug, fill and fixed are auto layout's sizing modes, so a design converts to LAYR almost directly. --- # Animation Source: https://layr.dynshift.com/docs/animation # Animation Wrap an object in `Animate` and every change to it moves instead of jumping, whatever caused the change: state, a Function, an [Inject](/docs/export-extract-inject), the theme, or a frame switch. ```layr Page( .name(Grow) .route('/') var bool big = false Scaffold( .config(color: canvas) .body( Column( .config(gap: 16, padding: all(24)) Animate( .eases(w: spring.bounce) .obj( Container( .config( w: big ? 320 : 160 h: 96 color: big ? violet : accent cornerRadius: big ? 28 : 12 padding: all(16) ) .obj( Text( .config(color: onAccent, weight: semibold) .obj(big ? 'w: 320' : 'w: 160') ) ) ) ) ) Button(.preset(default) .config(label: 'Toggle') .fnc { big = !big }) ) ) ) ) ``` The width, the corners and the colour all change in one step, and `Animate` moves each from where it is to where it is going. `.eases(w: spring.bounce)` gives the width its own motion; everything else uses the default. > **Try it** > - Press **Toggle** twice quickly. The second change starts from wherever the first had got to, instead of restarting: springs keep their speed. > - Remove the whole `Animate(...)` wrapper, leaving the `Container`: the same changes now jump. ## Choosing a motion Springs feel physical: they have no fixed duration and settle when they settle. One button moves all four bars below, each with a different spring: ```layr Page( .name(Springs) .route('/') var bool on = false Scaffold( .config(color: canvas) .body( Column( .config(w: fill, gap: 10, padding: all(24)) Button( .preset(default) .config(label: 'Move all four') .fnc { on = !on } ) Animate( .config(ease: spring.gentle) .obj( Container( .config( w: on ? 300 : 120 h: 36 color: ember cornerRadius: 8 objAlign: midLeft padding: sym(x: 12) ) .obj(Text(.config(color: onAccent) .obj('gentle'))) ) ) ) Animate( .config(ease: spring.snappy) .obj( Container( .config( w: on ? 300 : 120 h: 36 color: gold cornerRadius: 8 objAlign: midLeft padding: sym(x: 12) ) .obj(Text(.config(color: ink) .obj('snappy'))) ) ) ) Animate( .config(ease: spring.bounce) .obj( Container( .config( w: on ? 300 : 120 h: 36 color: teal cornerRadius: 8 objAlign: midLeft padding: sym(x: 12) ) .obj(Text(.config(color: onAccent) .obj('bounce'))) ) ) ) Animate( .config(ease: spring.slow) .obj( Container( .config( w: on ? 300 : 120 h: 36 color: violet cornerRadius: 8 objAlign: midLeft padding: sym(x: 12) ) .obj(Text(.config(color: onAccent) .obj('slow'))) ) ) ) ) ) ) ) ``` For motion with a fixed length, use an ease and a duration: `.config(ease: ease.out, duration: 200ms)`. Small, frequent UI changes (a hover, a toggle) want short, snappy motion; large movements that explain something can take longer. | Key or modifier | Meaning | |---|---| | `.config(ease: spring.snappy)` | The motion for every property | | `.eases(w: spring.gentle, color: ease.inOut)` | Motion per property | | `.config(duration: 300ms)` | The duration for eased (non-spring) motion | | `.enter(fade, slide(y: 20))` | Motion when the object appears | | `.exit(fade)` | Motion when it leaves an `If` branch | Springs: `spring.gentle`, `spring.snappy`, `spring.bounce`, `spring.slow`. Eases: `ease.linear`, `ease.in`, `ease.out`, `ease.inOut`, `ease.emphasized`. ## Entering and leaving An object inside `If` appears and disappears. `.enter(...)` and `.exit(...)` give it motion on the way in and out, and the outgoing branch finishes its exit before it is removed: ```layr Page( .name(Toast) .route('/') var bool shown = false Scaffold( .config(color: canvas) .body( Column( .config(gap: 16, padding: all(24)) Button( .preset(default) .config(label: shown ? 'Dismiss' : 'Save') .fnc { shown = !shown } ) If( .cnd(shown) .obj( Animate( .config(duration: 220ms, ease: ease.out) .enter(fade, slide(y: 12)) .exit(fade) .obj( Container( .config( .border(color: line, width: 1) color: panel cornerRadius: 12 padding: sym(x: 16, y: 12) ) .obj(Text('Saved. Your changes are live.')) ) ) ) ) ) ) ) ) ) ``` > **Try it** > - Change `slide(y: 12)` to `slide(y: -12)`: it now drops in from above. > - Remove `.exit(fade)`: it still arrives smoothly, but leaves at once. ## Sequences Steps and TypeScript bodies can pace changes over time. Here one action plays a small routine: ```layr Page( .name(Sequence) .route('/') Scaffold( .config(color: canvas) .body( Column( .config(gap: 16, padding: all(24)) Animate( .config(ease: spring.snappy) .obj( Container( .id(box) .config(w: 80, h: 80, color: accent, cornerRadius: 16) ) ) ) Button( .preset(default) .config(label: 'Play') .fnc( .loop( .times(2) .exe(box.w = 240) .wait(300ms) .exe(box.w = 80) .wait(300ms) ) ) ) ) ) ) ) ``` `box.w = 240` writes the box's width from an action, and `Animate` moves it there. See [Functions and events](/docs/functions) for every step. ## Reduced motion LAYR respects the reader's reduced-motion setting: changes apply instantly. `motion: always` on an `Animate` overrides this for motion that carries meaning. --- # Effects Source: https://layr.dynshift.com/docs/effects # Effects `Blur` blurs one of two things. With `blurOn: background` (the default) it blurs whatever shows through it from behind: frosted glass. With `blurOn: object` it blurs its own content. Either way the blur is `uniform`, or `progressive`: clear on one side and deepening toward an `edge`. ## Frosted glass A header that stays readable over anything scrolling under it. The Blur is transparent: its `color` is a thin veil of the page colour, and the blur does the rest. Scroll the feed. ```layr Page( .name(Glass) .route('/') Scaffold( .config(color: canvas) .body( Stack( .config(w: fill, h: 340, clip: true) Scroll( .config(w: fill, h: fill) Column( .config( w: fill gap: 12 padding: only(left: 16, right: 16, top: 76, bottom: 16) ) Container( .config( w: fill h: 110 color: LinearGradient(.colors(ember, gold)) cornerRadius: 14 ) ) Container( .config( w: fill h: 110 color: LinearGradient(.colors(violet, teal)) cornerRadius: 14 ) ) Container( .config( w: fill h: 110 color: LinearGradient(.colors(ink, ember)) cornerRadius: 14 ) ) Container( .config( w: fill h: 110 color: LinearGradient(.colors(teal, gold)) cornerRadius: 14 ) ) ) ) Position( .config(left: 0, right: 0, top: 0) .obj( Blur( .config( w: fill color: panel.alpha(55%) padding: sym(x: 20, y: 18) value: 16 ) .obj( Text( .config(size: 17, color: ink, weight: semibold) .obj('Frosted header') ) ) ) ) ) ) ) ) ) ``` > **Try it** > - Change `value: 16` to `value: 4`, then to `40`. > - Change `panel.alpha(55%)` to `panel.alpha(90%)`: more veil, less of what is behind. ## Progressive blur A progressive blur is clear on one side and strongest at `edge`, so content dissolves instead of stopping at a hard line: the end of a feed, the space under a floating toolbar, the bottom of a hero image. It is drawn as a stack of thin blur layers, each covering an overlapping band, so no steps show. `curve: exponential` (the default) keeps the clear side clear for longest, which reads as the most natural fade. `extent` sets how far from the edge the blur reaches, and `fade` melts the blurred edge into a colour so text laid over it stays readable. ```layr Page( .name(Dissolve) .route('/') Scaffold( .config(color: canvas) .body( Stack( .config(w: fill, h: 360, clip: true) Column( .config(w: fill, gap: 10, padding: all(16)) Text(.config(size: 13, color: muted) .obj('Today')) Container( .config( w: fill h: 72 color: LinearGradient(.colors(ember, gold)) cornerRadius: 12 ) ) Container( .config( w: fill h: 72 color: LinearGradient(.colors(violet, accent)) cornerRadius: 12 ) ) Container( .config( w: fill h: 72 color: LinearGradient(.colors(teal, violet)) cornerRadius: 12 ) ) Container( .config( w: fill h: 72 color: LinearGradient(.colors(gold, ember)) cornerRadius: 12 ) ) ) Position( .config(bottom: 0, left: 0, right: 0) .obj( Blur( .config( w: fill h: 180 edge: bottom fade: canvas type: progressive value: 28 ) ) ) ) ) ) ) ) ``` > **Try it** > - Change `edge: bottom` to `edge: top` and move the Position to `top: 0`. > - Add `curve: linear`: the blur starts sooner, and the fade shows its steps less gracefully. > - Remove `fade: canvas`: the blur stays, without melting into the page. | Key | What it does | | --- | --- | | `edge` | The side where the blur is strongest: `bottom`, `top`, `left` or `right`. | | `value` | The blur radius at the edge. | | `extent` | How far from the edge it reaches: a length, or a percentage of the Blur (default `100%`). | | `layers` | How many layers draw it (default 8). More is smoother; 6 to 12 covers almost everything. | | `curve` | `exponential` (default), `ease` or `linear`. | | `fade` | A colour the edge melts into, usually the page background. | ## Blurring an object's own content `blurOn: object` blurs what is inside the Blur. Its `value` is animatable: wrap it in `Animate` and change it, and the content comes into focus. ```layr Page( .name(Spoiler) .route('/') var bool hidden = true Scaffold( .config(color: canvas) .body( Column( .config(w: fill, gap: 16, padding: all(24)) Animate( .config(duration: 450ms, ease: ease.out) .obj( Blur( .config( blurOn: object color: panel cornerRadius: 12 padding: all(20) value: hidden ? 10 : 0 ) .obj( Text( .config(size: 18, color: ink, lineHeight: 1.5) .obj( 'The layout was authoritative all along: every number in the file was the one on the screen.' ) ) ) ) ) ) Button( .preset(default) .config(label: hidden ? 'Reveal the ending' : 'Hide it again') .fnc { hidden = !hidden } ) ) ) ) ) ``` Blur is drawn by the browser's compositor (`backdrop-filter` and `filter`). A few blurred areas cost little; a full-screen progressive blur over video, with many layers, is the expensive end. --- # Export, Extract, Inject Source: https://layr.dynshift.com/docs/export-extract-inject # Export, Extract, Inject Any part of a LAYR app can **read** (Extract) and **change** (Inject) the features of any object in any page or widget. Changes are layers: ordered, reversible, and known to the compiler. ## Protected by default Objects are protected: nothing outside an object's owner changes its params or features. An Extract or Inject is the explicit way in, and using one is what makes a feature changeable. Mark anything that must never change from outside with `!mut`. ## Addressing objects By **id**: ```layr noexec SomePage.card // the object with .id(card) in SomePage Card.frame // .id(frame) inside the Card widget: every instance card // an id in the same Page or Widget ``` By **lookup path**, one level per `.`, using the lowercase widget name, a slot name or an id: ```layr noexec DemoPage.scaffold.body.mid.column.text // the only Text in that Column DemoPage.scaffold.body.mid.column.text(1) // the second of several Texts (zero-based) SomePage.card.column.text(0) // mix ids and paths ``` A path continues **into a widget instance**: past the instance, it names objects inside that widget, so it reaches one instance and leaves the others alone. Naming the widget's root is optional, since the instance is its root: ```layr noexec Room.scaffold.body.column.booking(1).text(0) // the first Text in the second Booking only Room.scaffold.body.column.booking(1).row.text(0) // the same object, naming Booking's root Row Booking.row.text(0) // from the widget's definition: that Text in every Booking ``` The compiler resolves every address. A path that breaks after an edit is an error (L3301), and a bare name with several matches is ambiguous (L3303) with fixes listing the `(n)` choices. Ids survive edits; paths are handy for objects you did not name. ## Extract: read An Extract reads a feature of another object. Here a page reads its own card three ways: the padding now, the padding before any Inject, and the size it rendered at: ```layr Page( .name(Reader) .route('/') Extract(.from(Reader.card) insets pad = card.padding) Extract(.from(Reader.card) .exeOrder(-1) insets original = card.padding) Inject(.into(Reader.card) .exeOrder(0) card.padding = original * 2) Scaffold( .config(color: canvas) .body( Column( .config(gap: 8, padding: all(24)) Container( .id(card) .config(color: accent, cornerRadius: 12, padding: all(12)) .obj(Text(.config(color: onAccent) .obj('The card'))) ) Text('now: $pad') Text('before any Inject: $original') Text( .config(color: muted) .obj( 'rendered: ${Math.round(Reader.card.size?.w ?? 0)} × ${Math.round(Reader.card.size?.h ?? 0)}' ) ) ) ) ) ) ``` > **Try it** > - Change `original * 2` to `original * 3`: the card grows, "now" follows, "before any Inject" does not. > - Delete the `Inject` line: both readouts show the declared `all(12)`. Extracts are reactive: they update when the feature changes. Features are config keys (`padding`, `w`, `color`…), widget params, what the object shows (`obj`, [below](#objects-change-what-an-object-shows)), and the **rendered** features `size`, `pos` and `visible`, measured from layout. You cannot change a measurement: `pos` and `visible` are read-only, and writing `size` changes the object's own `size` key instead (a Text's font size, a box's size), while reading it still gives what was measured. `.exeOrder(k)` reads the value **below** that point in the cascade: every Inject with a lower order, none at `k` or above. Here `.exeOrder(-1)` reads the card before the Inject at `0`. Inline references (`Source.card.size` in an expression) are Extracts too. ## Inject: change An Inject changes a feature of another object, from anywhere. Here a widget, while it is shown, doubles a card's padding and recolours it: ```layr Page( .name(Target) .route('/') var bool loud = false Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Container( .id(card) .config(color: accent, cornerRadius: 12, padding: all(10)) .obj(Text(.config(color: onAccent) .obj('card'))) ) Button( .preset(default) .config(label: loud ? 'Remove Louder' : 'Show Louder') .fnc { loud = !loud } ) If(.cnd(loud) .obj(Louder())) ) ) ) ) Widget( .name(Louder) Inject(.into(Target.card) .exeOrder(0) card.padding = card.padding * 2) Inject(.into(Target.card) .exeOrder(1) card.color = teal) .obj( Text(.config(color: muted) .obj('Louder is shown, so its two layers apply')) ) ) ``` > **Try it** > - Press the button twice. When `Louder` goes away, its layers go with it and the card **reverts**: nothing had to undo them. > - Give the second Inject `.exeOrder(0)` too: two layers on different features can share an order, so it still compiles. Now change it to `card.padding = all(4)` with `.exeOrder(0)`: two layers on the same feature at the same order is an error (L3102). Each Inject is a **layer**: a change applied on top of the value below it. The rules: - Layers apply in ascending `.exeOrder`, like `z-index`: any integer works, negative and large ones included. `-9999` runs under everything and `9999` over everything; the highest order has the last word. Two Injects on the same feature with the same order are an error (L3102). Injects without an order go after ordered ones, in file order, with a warning if two share a feature (L3103). - `.exeOrder` has short forms for fast typing: `.exeOrd`, `.eOrd` and `.eO`. `layr format` writes them as `.exeOrder`. - Inside an Inject, the target's current value is the value from the layer below: `card.padding * 2` doubles whatever came before. - A layer lives as long as its owner: a file-level Inject is permanent, a Page's while it is shown, a Widget's while that instance is shown. When the owner goes away, the layer goes and the value **reverts**. - Writing a feature from a Function (`card.w = 360`) is the imperative form. It sets the base value; layers still apply above it. ## Objects: change what an object shows What an object shows is a feature too: `.obj` names it. A Text's `obj` is its text, a Container's is its object, a Column's or Row's are its objects. Read it, write it and inject it like `padding`: ```layr Page( .name(Order) .route('/') var bool shipped = false Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24)) Container( .id(card) .config( .border(color: line, width: 1) color: panel cornerRadius: 12 padding: all(16) ) .obj( Column( .config(gap: 4) Text( .id(title) .config(size: 17, weight: semibold) .obj('Order 1042') ) Text(.id(status) .config(color: muted) .obj('Packing')) ) ) ) Button( .preset(default) .config(label: shipped ? 'Undo' : 'Ship it') .fnc { shipped = !shipped } ) If(.cnd(shipped) .obj(Shipped())) ) ) ) ) Widget( .name(Shipped) Inject(.into(Order.status) status.obj = 'Shipped, arriving Tuesday') Inject(.into(Order.title) title.obj = title.obj + ' · paid') .obj( Text(.config(color: muted) .obj('Shipped is shown, so its layers apply')) ) ) ``` > **Try it** > - Press **Ship it**, then **Undo**: the text changes and changes back, like any layer. > - Replace the card's whole object: add `Inject(.into(Order.card) card.obj = Text(.config(color: accent) .obj('Delivered')))` inside `Shipped`. > - Write `status.obj = Text('x')`: a Text shows text, so that is an error (L1007). The same works for widgets and lists. A widget instance's params are features by name, and its `obj` is what the caller put in its `.obj`. A list's objects are replaced as a list: ```layr Widget( .name(Tag) .param(txt label = 'New') .obj( Container( .config(color: accent, cornerRadius: 999, padding: sym(x: 10, y: 4)) .obj( Text( .config(size: 13, color: onAccent, weight: medium) .obj(param.label) ) ) ) ) ) Page( .name(Menu) .route('/') Scaffold( .config(color: canvas) .body( Column( .config(gap: 12, padding: all(24), xAlign: start) Tag(.id(tag)) Column(.id(dishes) .config(gap: 4) Text('Soup'), Text('Bread')) ) ) ) ) Inject(.into(Menu.tag) tag.label = 'Today only') Inject( .into(Menu.dishes) dishes.objs = [Text('Soup'), Text('Bread'), Text(.config(color: accent) .obj('Pie'))] ) ``` > **Try it** > - Delete the first Inject: the Tag shows its default `New` again. > - Change the list to `[Text('Closed today')]`: the Column shows one object. The rules: - A path that **ends** on a slot names the slot: `card.obj`, `list.objs` (`obj` and `objs` both name an object's default slot), `Menu.scaffold.bar`. A path that goes on descends into it: `card.obj.column.text(1)` is the second Text in the card. - Objects written as values compile like the same objects in the layout: `card.obj = Text('Hi')`, `list.objs = [Text('a'), Text('b')]`. A Text's `obj` takes text. - Inside an Inject, `title.obj` is the value below, so `title.obj + ' · paid'` extends the text. An Extract of a Text's `obj` reads its text. - A Function writes it too: `status.obj = 'Shipped'`. ## The cascade Every feature resolves in one order: ```text schema default → theme → preset → config → .at(frame) → writes → injections by exeOrder → final ``` Ask the analyzer for any feature's cascade: ```sh layr analyze --explain Target.card.padding ``` ```text Target::card.padding declared (config) inject exeOrder 0 src/pages/target.layr:12:41 extract at exeOrder -1 src/pages/reader.layr:3:52 ``` In VS Code, **Find References** on an id lists every Extract and Inject of that object. ## `!mut` ```layr Container(.id(logo) .config(w: 40, !mut color: ink)) ``` Any Extract/Inject change to a `!mut` feature is an error (L3101); at runtime it is ignored with a warning. For system code that must override it, `Inject(.force ...)` is allowed only in files listed under `access.force` in `layr.yaml`, and the analyzer lists every forced injection. ## Export: name what you share `.export(...)` gives features names under an export id, like a small public interface for an object: ```layr Page( .name(Shared) bind size doubled = boxFeatures.boxSize * 2 Scaffold( .body( Column( Container( .config(w: 120, h: 30, color: violet) .export(id: boxFeatures, size boxSize = container.size) ) Text('doubled: $doubled') ) ) ) ) ``` Export is optional: every feature is already reachable by id or path. Use it to name and document what other code should use. ## From React React code joins the same cascade: `useExtract("Target::card", "padding")` reads a feature and `inject(address, key, order, fn)` adds a layer and returns a function that removes it. The key `"obj"` is what the object shows, so `inject("Order::status", "obj", 0, () => "Shipped")` changes a Text. See [React interop](/docs/react). --- # React interop Source: https://layr.dynshift.com/docs/react # React interop LAYR's first render target is React, so the React ecosystem is one explicit, typed boundary away. ## JSX inside LAYR Write JSX wherever LAYR expects an object: an HTML element or a React component, with LAYR objects inside it or around it. Lowercase tags are HTML elements, capitalised tags are React components you import, attributes are `"strings"` or `{ TypeScript }`, and `{ }` in the children is a TypeScript expression. ```layr Page( .name(Mixed) .route('/') var int likes = 3 Scaffold( .config(color: canvas) .body( Column( .config(w: fill, gap: 16, padding: all(24))
Text(.config(size: 18, weight: semibold, color: ink) .obj('A LAYR Text inside a section'))

Plain HTML, {likes} likes.

Container( .config( .border(color: line, width: 1) color: panel cornerRadius: 12 padding: all(16) ) .obj() ) ) ) ) ) ``` That is the whole bridge to the React ecosystem: a component library's ``, a chart's ``, or `` go in the tree as they are, and LAYR objects inside them keep their layout, Design Scale and E/E/I. A LAYR object inside an element is reachable through it: the `Text` above is `Mixed.scaffold.body.column.section.text`. JSX text follows React's rules: line breaks and the spaces around them collapse. A word followed directly by `(` inside JSX text (`Container(`) is read as a LAYR object; write `{'Container('}` for the text. `layr format` leaves JSX exactly as you wrote it. ## React components as LAYR objects Import a React component and use it as an object. `.props(...)` passes props to it; `.config(...)` takes only LAYR layout keys, applied to a box around it; `.on(change: ...)` maps to `onChange`: ```layr import { Counter } from '../react/Counter.tsx' Page( .name(Interop) var int changes = 0 Scaffold( .body( Column( .config(gap: 12, padding: all(24)) Counter( .config(padding: all(8)) .props(start: 5, label: 'React counter') .on(change: { changes++ }) ) Text('changes seen by LAYR: $changes') ) ) ) ) ``` Named slots become ReactNode props: `.slot(icon: Icon('star'))`. ## Hooks React hooks run inside a `.react { }` block on a Page or Widget. Its variables are in scope for the whole definition: ```layr noexec import { useQuery } from '@tanstack/react-query' Page( .name(Stats) .react { const stats = useQuery({ queryKey: ['stats'], queryFn: getStats }) } Scaffold(.body(Text('Visitors: ${stats.data?.visitors ?? "…"}'))) ) ``` Calling a hook anywhere else is an error (L9002). ## Providers ```layr noexec import { QueryClient, QueryClientProvider } from '@tanstack/react-query' App(.providers(QueryClientProvider(.props(client: new, QueryClient())))) ``` ## Styling React components Theme colours and the design unit are CSS variables inside a LAYR app: `var(--layr-color-brand)`, and `calc(200 * var(--ds) / 1000)` for 200 design px. Pass classes with `.props(className: '...')`. ## LAYR in React apps Add the Vite plugin and import `.layr` files as React components: ```ts // vite.config.ts import layr from "@dynshift/layr/vite"; export default { plugins: [layr()] }; ``` ```tsx import "@dynshift/layr/styles.css"; import { LayrProvider } from "@dynshift/layr/react"; import Card from "./card.layr"; export function App() { return ( ); } ``` Params become props; the widget's `.obj` is `children`. ## Core widgets as TSX For code that is not ready for `.layr`, the core widgets are also React components with LAYR units: ```tsx import "@dynshift/layr/styles.css"; import { Container, LayrProvider, Text } from "@dynshift/layr/tsx"; Hello ; ``` These lower styles at runtime, so the compiler's checks, static CSS and per-frame interpolation are not available. ## Export/Extract/Inject from React ```tsx import { inject, useExtract } from "@dynshift/layr/react"; const size = useExtract("Target::card", "size"); const remove = inject("Target::card", "color", 0, () => "#ef4444"); ``` Objects you address from React need an `.id`. --- # Addons Source: https://layr.dynshift.com/docs/addons # Addons An addon is an npm package of LAYR widgets. Core LAYR holds meaning and mechanics (layout, Design Scale, state, motion, accessibility); addons hold appearance and opinions: styled kits, showpiece components, design systems, integrations. ## Use an addon ```sh npx layr add google_fonts npx layr add @someone/layr-glass@^1.2.0 ``` `layr add` resolves the addon (by its LAYR id through the [Library](/library), or by npm name), checks that it supports your LAYR version, installs it with your package manager and records it in `layr.yaml`. Then import its widgets: ```layr noexec import { GoogleFonts } from '@dynshift/layr-google-fonts' Page( .name(Home) Scaffold( .body(Text(.config(size: 40, font: GoogleFonts.Fraunces) .obj('Hello'))) ) ) ``` Addons ship `.layr` source that your compiler compiles with your app: they get the same checks, the same static CSS and only what you use ends up in the bundle. Other commands: `layr remove`, `layr update`, `layr outdated`, `layr search `, `layr info `, and `layr eject ` to copy an addon's source into your project so you can edit it. ## Create an addon ```sh npx @dynshift/layr create addon glass cd glass npm install npx layr dev ``` ```text glass/ package.json npm package with a "layr" manifest layr.yaml kind: addon src/index.layr the addon's widgets examples/*.layr example pages: the addon gallery .github/workflows/ CI and release (npm publish with provenance) ``` The `layr` field in `package.json`: ```json "layr": { "id": "glass", "kind": "component", "layr": "^3.0.0", "entry": "./src/index.layr", "categories": ["components"], "tags": ["glassmorphism"], "examples": "./examples" } ``` - `id`: snake_case, what people type in `layr add`. - `layr`: the LAYR versions the addon supports (a semver range). - `entry`: the `.layr` file whose widgets are the addon's public API. `layr dev` serves the examples; `layr test` screenshots them at every design frame; `layr docs` writes `docs/API.md` from your widgets' params. ## Publish ```sh npx layr pack # validate: manifest, analyzer, LAYR range, contents, no install scripts npx layr publish # validate, then npm publish ``` Publishing is plain npm: your package, your scope, your account. From CI, the template's `release.yml` publishes with npm provenance when you push a `v*` tag. To list an addon in the [Library](/library) on layr.dynshift.com, open a pull request adding one file to the catalogue in the LAYR repository. See [Library: publish](/library/publish). ## Rules for addons - Ship `.layr` source; no install scripts; under 5 MB. - Declare `@dynshift/layr` as a peer dependency. - Keep widgets unstyled only where the core already is; addons are where opinions live. --- # Deploy Source: https://layr.dynshift.com/docs/deploy # Deploy ```sh npx layr build ``` `dist/` now holds a static site: every page without route params is prerendered to HTML (fast first paint, readable by search engines), then becomes interactive in the browser. `404.html` hands unknown paths to the client router. `--base /my-app/` builds for a sub-path. Any static host works. ## GitHub Pages ```yaml # .github/workflows/pages.yml name: Pages on: push: branches: [main] permissions: contents: read pages: write id-token: write jobs: deploy: runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: node-version: 22 - run: npm ci - run: npx layr build - uses: actions/upload-pages-artifact@v3 with: path: dist - id: deployment uses: actions/deploy-pages@v4 ``` For a project site at `https://you.github.io/my-app/`, build with `npx layr build --base /my-app/` or set `base: /my-app/` in `layr.yaml`. ## Other hosts Cloudflare Pages, Netlify and Vercel: build command `npx layr build`, output directory `dist`. --- # CLI Source: https://layr.dynshift.com/docs/cli # CLI `layr` comes with `@dynshift/layr`. Run it with `npx layr` inside a project, or install it globally with `npm i -g @dynshift/layr`. | Command | What it does | |---|---| | `layr create ` | New project (`--template app` or `blank`) | | `layr create addon ` | New addon | | `layr dev` | Dev server with reload on save (`--port`, `--host`) | | `layr build` | Static site in `dist/`, routes prerendered (`--base`, `--out`, `--no-prerender`) | | `layr preview` | Serve `dist/` | | `layr format` | Canonical form (`--check` for CI) | | `layr analyze` | All diagnostics (`--json`, `--info`, `--explain Page.id.feature`) | | `layr explain ` | Explain a diagnostic, e.g. `layr explain L3102` | | `layr test` | Screenshot every route at every design frame and compare (`--update`, `--frames m,w`) | | `layr add ` | Install addons | | `layr remove ` | Uninstall addons | | `layr update [addon]` | Update addons | | `layr outdated` | Addons with newer versions | | `layr search ` | Search addons | | `layr info ` | An addon's details | | `layr pack` | Validate and pack an addon | | `layr publish` | Validate and publish an addon to npm | | `layr eject ` | Copy an addon's source into your project | | `layr docs` | API docs for an addon's widgets | | `layr skills install` | Give AI agents the LAYR Skill | | `layr doctor` | Check the environment and project | | `layr lsp` | Language server (stdio), used by editors | | `layr mcp` | MCP server (stdio), used by AI agents | ## layr.yaml ```yaml name: my-app layr: ^3.0.0 addons: google_fonts: ^1.0.0 base: / access: force: ["src/system/**"] # files allowed to use Inject(.force ...) targets: [react] ``` --- # Migrating from v1 Source: https://layr.dynshift.com/docs/migrating-from-v1 # Migrating from v1 LAYR 1.x was a set of Flutter-style React components. LAYR 3 is a compiled language; v2 was never released. v1 keeps working: it stays on npm, and `npm i @dynshift/layr@v1` installs the latest 1.x and its source is on the `v1` branch. ## Two ways forward **Keep your React app and switch component by component.** `@dynshift/layr/tsx` has the core widgets as React components: | v1 | v3 TSX | |---|---| | `` | `` | | `` | `` | | `` | `` | | `` | `{20}` | | `` | `padding={16}` on the parent | | `` | `` | | `` | `` | Numbers are now design pixels: they scale with the screen. Where v1 relied on exact pixels, pass `px(12)` (from `@dynshift/layr/runtime`) in TSX, or write `12px` in `.layr`. **Move pages to `.layr`.** New pages get the compiler's checks, static CSS, Design Scale interpolation and Export/Extract/Inject. `.layr` files import into your React app through the Vite plugin, so both can live side by side. ## Naming - `x` and `y` now mean position and axis; sizes are `w`, `h` and `size`. - `xAlign` is always horizontal and `yAlign` always vertical, in Rows and Columns alike. - `Centre`/`Center` is `Mid`; `SizedBox` is `Gap`; `Expanded` is `Expand`. --- # Troubleshooting Source: https://layr.dynshift.com/docs/troubleshooting # Troubleshooting Every diagnostic has a code. `layr explain ` prints the explanation; the [errors reference](/errors) lists them all. **"Unknown config key" (L1002).** The message suggests the closest key, including aliases (`widht` → `w`). In VS Code, the quick fix applies it. **An object is 0 × 0.** A `hug` Container with no child has nothing to hug (L2003). Give it a size. **`fill` does nothing, or L2001.** `fill` shares space its parent has spare. In a scroll area there is no end to share; give the object a length. **A row wraps or stacks unexpectedly.** That is [adaptation](/docs/layout#when-space-runs-out). Set `overflow` explicitly, or give children `shrink` values. **An Inject has no effect.** Check `layr analyze --explain Page.id.feature`: another layer with a higher `exeOrder` may override it, the feature may be `!mut` (L3101), or the Inject's owner may not be shown (its layer only exists while it is). **Two Injects conflict (L3102).** Give them different `.exeOrder` values. **A lookup path broke (L3301) or is ambiguous (L3303).** Paths follow the object tree; after restructuring, update the path or give the object an `.id`. **Text does not grow with browser zoom.** It does: LAYR fonts keep a `rem` part. If you see L6101, a font's range across frames is too wide. **Hydration warnings after `layr build`.** Values that depend on the screen at runtime (not in CSS) can differ from the prerendered HTML. Prefer `.at(frame, ...)`, which compiles to CSS. **`layr test` fails after a deliberate change.** Look at `tests/frames/*.diff.png`, then accept with `layr test --update`. --- # AI agents Source: https://layr.dynshift.com/docs/ai # AI agents LAYR is designed to be written by people and by AI agents. Agents know React and Flutter well, and those instincts produce wrong LAYR. Three things fix that. ## The LAYR Skill ```sh npx layr skills install ``` This writes the LAYR Skill to `.claude/skills/layr/` (for Claude Code) and a short section to `AGENTS.md` (for other agents). The Skill carries the language rules, the canonical form, every widget and key, the diagnostics, patterns and the mistakes agents usually make, and matches your installed LAYR version. `layr skills update` refreshes it after upgrading LAYR. The Skill tells agents to work in a loop: write, `layr format`, `layr analyze --json`, fix until clean. LAYR's diagnostics are precise enough for agents to correct themselves. ## The MCP server ```sh npx layr mcp ``` An MCP server over stdio with tools to list widgets, get a widget's full schema, explain a diagnostic, check a snippet, format code, analyze the project and search addons. Register it in your agent, for example in Claude Code: ```sh claude mcp add layr -- npx layr mcp ``` ## llms.txt [layr.dynshift.com/llms.txt](/llms.txt) indexes the docs for agents that read the web; `/llms-full.txt` is the whole documentation in one file.