# ModulateJS — markdown for UI interactions

ModulateJS is markdown for UI interactions: a consistent language for how UI can feel. Whether a human writes it or an LLM does, each has a language that can naturally express feel, play and fun. It is meant to be comprehensive: enough abstractions above Motion and the web APIs that custom JavaScript is never necessary. The aesthetic goals are speed, fidelity and control. A prototype is a link, and the link runs on your phone. This page is a short tour; every word, in full, is in [spec.md](https://modulatejs.com/spec.md). Every example runs as written: paste it into [coral.fm](https://coral.fm/).

Install: `<script src="https://unpkg.com/modulatejs"></script>` · `npm i modulatejs` · `npx modulatejs proto.js` · MCP: `npx -y modulatejs mcp` or `https://coral.fm/mcp`

## How it reads

```js
draw: {
  heart: circle(96, "coral")
  icon: emoji("♥", 40).center(heart)
  dots: row(3, circle(10, "grey")).at("center", 560)
}
burst: circle(7, "coral").around(heart, 10).hide()

heart.on("tap").spring("pop", 1.3)
burst.on(heart.tap).show().fly(48).fade().stagger(.02)
draw.drift(8)
```

A name and a colon always means "this name refers to what follows": a layer, or with braces a section, which is a group of every layer made inside it. A piece draws a layer, verbs chain on it, and after `.on(driver)` the verbs describe the other state instead.

## Pieces

Things an app is made of. Each looks finished with no arguments and starts centred. Numbers are sizes, strings are words or colours, layers become children.

### box · circle · pill · text · emoji

The plain shapes and type. With no string, text() hands out a line from the built-in bank.

```js
stack(text("Morning light", 34), text("Tuesday, 7:30").color("dim"), row(box(80), circle(), pill("Follow")).gap(16), emoji("☀️", 56)).gap(18)
```

### image · avatar

A photo by seed, a face by name, each on a placeholder that is the design when the network is off. A URL or a file name is your own picture, shown as it is: drop one onto coral and a transparent PNG stays transparent.

```js
stack(image("harbour", 300, 200), row(avatar("Addie Moreau"), avatar("Kenji Sato"), avatar("Noor Haddad")))
```

### scroller · a feed

A region that scrolls on its own, under things that don't: momentum, a rubber band, and the same flick landing in the same place every time. feed.scroll drives everything around it; .range(120) is the first 120 points, for a header that shrinks. "x", "page" (a carousel) and "both" are the other ways it goes.

```js
header: card(390, 160).at(0, 0).glass()
feed: scroller(stack(20, card())).at(0, 160)
header.on(feed.scroll.range(120)).height(88).blur(8)
```

### card · content

A card stacks what you give it; strings are its words. With nothing, the words come from a demo bank, and content() swaps in yours.

```js
content({ titles: "Canvas tote, Stone mug, Linen apron", prices: "$48, $22, $65" })
stack(3, card()).gap(16).scale(.72)
```

### sheet

Reads the way you would describe it: what goes in it, in order, how far it rises, how it lets go.

```js
buttons: row(pill("Cancel", "fill"), pill("Add to bag", "coral")).spread()
item: sheet(buttons, image("tote", 342, 150), "Canvas tote", "$48 · two left in sand")

item.on("tap").rise("half").drag("y").dismiss()
```

### row · stack · grid · sections

row, stack and grid lay layers out. A section, name: { … }, is a group of the layers made inside it (group(a, b) is the same for a set that isn't a block): every verb runs on each member, and a "<…>" pattern gives each its own value.

```js
all: {
  a: box(70, "coral").at(40, 200)
  b: box(70, "plum").at(160, 260)
  c: box(70, "mint").at(280, 200)
}
all.radius("<8 35 20>").on(lfo("<.2 .3 .25>")).y("<-16 -10 -14>")
all.on("tap").scale(1.4).fade().curve("out", .2)
```

### tabbar · messages

Bottom tabs whose .page is a driver anything can follow; messages(n) is a conversation.

```js
tabs: tabbar("Home Search Saved")
screens: row(3, card()).gap(48).at(24, 120)
screens.on(tabs.page).x(-2 * 390)
```

## Placement

Everything is a transform on the device's screen, origin top-left. Placement happens when the line runs, so refer to layers that already exist.

### at · below · right · size

at() takes numbers or words, with margins and safe areas respected. below, above, left and right sit next to another layer.

```js
photo: image("tote", 220)
name: text("Canvas tote", 22).below(photo, 16)
price: text("$48").color("dim").below(name, 4)
tag: pill("new", "coral").size(64, 28).right(name, 10)
box(60, "fill").at("left", "top")
```

### center(layer) · around

Centring on a layer also rides on it. around(layer, n) puts n copies on a ring just outside it, and fly() sends them outward.

```js
sun: circle(90, "sun")
face: emoji("😎", 44).center(sun)
rays: circle(10, "sun").around(sun, 10)
sun.on("tap").spring("bounce", 1.25)
rays.on(sun.tap).fly(24).spring("bounce")
```

## Look

Colours are the palette (coral plum mint sky sun rose sand ink grey), a role (accent surface text dim fill line) or any CSS colour. Every look is a property, so after on() it animates.

### color · radius · shadow

shadow goes from 0 to 3 and belongs to a state like anything else: here it arrives under a finger. On a picture it follows the picture's own shape.

```js
b: box(150, "coral").radius(36).shadow(1)
b.on("hold").scale(1.12).radius(75).color("plum").shadow(3)
text("press and hold", 13).color("dim").below(b, 50)
```

### blur · glass

blur() sends the layer itself soft; glass() frosts what is behind it, the iOS material.

```js
photo: image("harbour").fill()
panel: sheet("Filters", "Frosted glass, like iOS").glass()
panel.on("tap").rise("half").drag("y")
photo.on(panel).blur(10).scale(.96)
```

### drift

Floats lazily around wherever it rests, each layer on its own path, and pauses under a finger. How far, then how slow: .1 is a soap bubble, .3 is a bee.

```js
sky: box("sky").fill()
a: circle(130, "white").opacity(.4).at(40, 140).drift(16)
b: circle(80, "white").opacity(.35).at(240, 110).drift(12, .16)
c: circle(100, "white").opacity(.3).at(150, 380).drift(14, .08, "hover")
a.drag().release("bounce")
```

### theme · device

theme("dark") flips everything and a palette name sets the accent; device() picks the screen. Both go first.

```js
device("iphone se")
theme("dark", "mint")
card(row(avatar(), text().bold()), image(), row(pill("Follow", "accent"), pill("Message", "fill")))
```

## Drivers

Everything that moves a prototype produces t from 0 to 1, so any driver can drive any change. After .on(driver), verbs describe the other state; the runtime moves what differs.

### on · tap

A tap plays the change with a spring; tapping again plays it back. A change that ends invisible comes back by itself.

```js
door: box(140, "plum")
door.on("tap").rotate(90).radius(70).color("coral")
```

### hold · release

1 while a finger is down. A bare hold goes in snappy and comes out settled; spring() changes the way in, release() the way out.

```js
b: box(180, "coral").radius(40)
b.on("hold").scale(.85).rotate(-45).release("bounce")
text("press and hold", 13).color("dim").below(b, 60)
```

### scroll · page

The screen scrolls natively and t is how far through you are. page(n) swipes sideways and snaps; peak() lights child i at page i.

```js
p: page(3)
slides: row(3, card()).gap(48).at(24, 150)
dots: row(3, circle(8, "grey")).gap(10).at("center", 620)
slides.on(p).x(-2 * 390)
dots.on(p).peak().color("coral").scale(1.5)
```

### time · lfo

A looping clock you can pause(), and an oscillator. Both are just t.

```js
track: pill(300, 6).color("grey")
bar: pill(300, 6).color("coral").width(0)
bar.on(time(3).pause(hold())).width(300)
dot: circle(24, "plum").below(track, 60)
dot.on(lfo(.5)).x(120).scale(1.6)
```

### several on one property

Continuous drivers add up. A state (hold, tap) takes the property over for as long as it is on, then hands it back.

```js
b: circle(160, "coral")
b.on(lfo(.3)).blur(8)
b.on("hold").scale(1.2).blur(0)
text("hold it: sharp, whatever the lfo is doing", 13).color("dim").below(b, 50)
```

### pick · others

One choice that many layers follow. Tapping a member of the group chooses its index; after .on(choice) a layer reads "<…>" by that index (pictures crossfade, words fade through), and a group has its chosen member in the other state. others is the rest of a group.

```js
photo: image("tote", 342, 300).at(24, 100)
name: text("Canvas tote", 26).at(24, 420)
strip: row(image("tote", 64, 64), image("mug", 64, 64), image("lamp", 64, 64)).at(24, 480)
choice: pick(strip)
photo.on(choice).image("<tote mug lamp>")
name.on(choice).words("<Canvas tote, Stone mug, Paper lamp>")
strip.on(choice).scale(1.12).ring("plum")
```

### go · back · stack.depth

A section is a screen. go() shows one on top of what is there ("cover", "push", "fade" or "sheet") and remembers it; back() undoes the last one, with everything that changed on the same tap, and so does a swipe in from the left edge. stack.depth is how deep you are.

```js
home: {
  rows: stack(pill("Canvas tote", 342), pill("Stone mug", 342, "fill")).at(24, 120)
}
detail: {
  bar: text("‹ Back").at(24, 64)
  photo: image("tote", 342, 300).at(24, 110)
}
rows.on("tap").go(detail, "push")
bar.on("tap").back()
home.on(stack.depth).blur(6)
```

### on(layer)

Follow another layer's change. The shade doesn't know about taps or drags; it knows about the sheet.

```js
shade: box("ink").fill().hide()
filters: sheet()
filters.on("tap").rise().drag("y")
shade.on(filters).opacity(.5)
```

## Feel

Five frozen presets, each a response time and a damping: snappy (0.15 s, no overshoot), settle (0.45 s, the default), pop (0.35 s, 13% past), lazy (0.9 s, heavy), bounce (0.5 s, 31% past). Nobody tunes stiffness in a prototype.

### spring · over · curve

spring(preset) picks the feel; over(seconds) makes the same preset quicker or slower without changing its bounce; curve() is a timed ease. spring alone after a tap is a kick.

```js
a: pill("pop").on("tap").scale(1.5).spring("pop")
b: pill("pop.over(.2)", "coral").on("tap").scale(1.5).spring("pop").over(.2)
c: pill("kick", "plum").on("tap").spring("bounce", 1.3)
stack(a, b, c).gap(50)
```

### origin

What stays still while a layer scales or rotates: a word, fractions, another layer, or "finger".

```js
more: pill("menu", "fill").size(96, 40).at(24, 70)
menu: card(text("Rename"), text("Duplicate"), text("Delete").color("coral"), 200, 140).at(24, 118).hide().scale(.3)
menu.on(more.tap).show().scale(1).origin("top left").spring("pop")

cover: card().at("center", 330)
cover.on("hold").scale(1.08).origin("finger")
```

### between · drive

The same two states for many layers at once. drive() takes any number of drivers.

```js
a: circle(90, "coral").at(60, 200)
b: box(90, "plum").at(240, 520)
between(() => {
  a.at(240, 520).size(40)
  b.at(60, 200).rotate(45)
}).drive(tap()).spring("bounce")
```

### range · fade · rise

range(a, b) gives a layer its own slice of t. fade() dissolves; rise(d) brings a hidden layer in from below.

```js
s: scroll(400)
one: card().at("center", 140)
two: pill("Continue", "plum").at("center", 640).hide()
one.on(s).range(0, .5).fade().scale(.9)
two.on(s).range(.5, 1).rise(40)
```

### fly · stagger

fly(d) sends a layer along its direction; stagger(s) lets a group's children go one after another.

```js
heart: circle(72, "coral")
icon: emoji("♥").center(heart)
burst: circle(6).around(heart, 8).hide()
heart.on("tap").spring("pop", 1.3)
burst.on(heart.tap).show().fly(40).fade().stagger(.03)
```

### into

A shared-element move: grow into another layer's frame while it fades in over the top. Tapping the destination goes back.

```js
thumb: card(image("tote", 120, 120), 152, 152).at(24, 120)
detail: box("white", image("tote", 342, 300), text("Canvas tote", 28), pill("Add to bag", "coral")).fill()
thumb.on("tap").into(detail).spring("snappy")
```

### modulate

The namesake. A Value that follows another through a mapping, in numbers or colours. Every property takes one.

```js
knob: circle(64, "ink").drag("x", [-120, 120])
bar: box(200, 24).at("center", 260)
bar.color(modulate(drag(knob).x, [-120, 0, 120], ["#3f8ef7", "#e9e9ee", "#e2694f"]))
bar.rotate(modulate(drag(knob).x, [-120, 120], [-30, 30]))
```

## Dragging

Pointer events, velocity, and the iOS rubber-band curve.

### drag · rubberband · release · dismiss

drag(axis, [min, max]) follows the finger. rubberband() resists, release() springs home, dismiss() lets a flick throw it away (it comes back: this is a toy).

```js
card().drag("x").rubberband(.8).release("settle").dismiss()
```

### snap · snapped

release() is the spring, snap() is the place: a point, the nearest of several, "edges", "corners", or some layers. Nearest is measured from where the flick was heading.

```js
slotA: box(84, "fill").at(50, 180)
slotB: box(84, "fill").at(256, 180)
coin: circle(56, "coral").at("center", 560)

coin.drag().snap(slotA, slotB).release("pop")
slotA.on(coin.snapped).color("mint")
slotB.on(coin.snapped).color("mint")
```

### toss · walls · bump

toss() keeps the flick and slows by friction; walls() makes the screen's edges something to come back off; bump() on a group makes its members push each other.

```js
a: circle(120, "sky").at(40, 120)
b: circle(90, "plum").at(230, 260)
c: circle(140, "mint").at(90, 480)
d: circle(70, "coral").at(260, 600)
things: group(a, b, c, d)
things.drift(10).drag().toss(.3).walls()
things.bump()
```

## Patterns

A string where a number or colour goes is a pattern, borrowed from Tidal: sequence "a b c", alternation "<a b>", subdivision "[a b]", repeat "a!4", and ~ for a rest.

### in time

Before on(), a cycle is two seconds and each step is sprung. "wave" "saw" "square" "noise" are continuous shapes; stagger() offsets them down a group.

```js
box().y("0 -80 0 [40 -40]").rotate("0 90 180 270").color("<coral plum> sun").move(0, -140)
row(7, pill(16, 80).color("sky")).gap(10).y("wave", 40).stagger(.12).move(0, 160)
```

### per tap

After on("tap"), a cycle is one tap, so alternation walks through its values a tap at a time.

```js
b: box(140)
b.on("tap").color("<plum mint sun coral>").rotate("<45 90 135 180>").spring("pop")
```

## The prototypes

- [01-swipe-to-dismiss.js](https://modulatejs.com/examples/01-swipe-to-dismiss.js)
- [02-pull-to-refresh.js](https://modulatejs.com/examples/02-pull-to-refresh.js)
- [03-sheet.js](https://modulatejs.com/examples/03-sheet.js)
- [04-push-pop.js](https://modulatejs.com/examples/04-push-pop.js)
- [05-tab-bar.js](https://modulatejs.com/examples/05-tab-bar.js)
- [06-onboarding-pager.js](https://modulatejs.com/examples/06-onboarding-pager.js)
- [07-like-button.js](https://modulatejs.com/examples/07-like-button.js)
- [08-story-progress.js](https://modulatejs.com/examples/08-story-progress.js)
- [09-card-expand.js](https://modulatejs.com/examples/09-card-expand.js)
- [10-shop-to-chat.js](https://modulatejs.com/examples/10-shop-to-chat.js)
- [11-chat-head.js](https://modulatejs.com/examples/11-chat-head.js)
- [12-bubbles.js](https://modulatejs.com/examples/12-bubbles.js)
- [13-pick.js](https://modulatejs.com/examples/13-pick.js)
- [14-screens.js](https://modulatejs.com/examples/14-screens.js)
- [15-feed.js](https://modulatejs.com/examples/15-feed.js)

## Licences

modulate.js (the runtime) is MIT. The editor page and CLI are AGPL-3.0. The spec and prototypes are CC BY 4.0. Built on Motion (MIT).
