# 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 CtrlS).
```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 `