Widgets & @bind
Controls in KaimonSlate are real Julia constructors, not a static macro DSL. @bind name Widget(...) binds a reactive variable name to a widget; because the widget is an ordinary constructor call, its arguments can be dynamic.
@bind hi Slider(10:10:200)
@bind n Slider(1:hi) # range depends on another binding — updates liveRead the bound variable from any other cell and it re-runs when the control changes:
using CairoMakie; set_theme!(theme_dark())
lines(1:n, sin.(range(0, 4π, n)))Widget catalog
| Constructor | Renders | Value |
|---|---|---|
Slider(range) / Slider(lo, hi; step) | slider | number |
RangeSlider(range) / RangeSlider(lo, hi; step) | two-thumb slider | (lo, hi) NamedTuple, always sorted and in bounds |
NumberField(default; min, max) | number input | number |
Checkbox(default) / Toggle(default) | checkbox / switch | Bool |
TextField(default) / TextArea(default; rows) | text input | String |
Select(options) / Radio(options) | dropdown / radio group | chosen option |
MultiSelect(options) / MultiCheckBox(options) | multi-select / checkbox group | Vector |
ColorPicker(default) | color picker | hex String |
DateField() / TimeField() | date / time picker | String |
FileUpload(; accept, maxbytes) | file picker / drop target | UploadedFile, or nothing until something is uploaded |
Button(label) | action button | click count |
TableSelect(data) | clickable table | clicked row as a NamedTuple (or nothing) |
PickPoint(; snap, snapto) | a click target on a figure (driven) | (x, y) NamedTuple in data coordinates |
PickRegion(; snap) | drag a box on a figure (driven) | (xlo, xhi, ylo, yhi) NamedTuple |
PickPath(; n, snap, snapto) | click n points on a figure (driven) | Vector of (x, y) |
playhead(anim) | animation player (driven) | current frame index |
RangeSlider binds one interval rather than two numbers, so the reader cannot cross the ends:
@bind span RangeSlider(400:4000; default = (1500, 1800), label = "region")
lo, hi = span # destructures · span.lo / span.hi by nameFileUpload stores the reader's bytes under the notebook's datadir() and binds an UploadedFile with .path (a real path you can read), .name, .size and .mime:
@bind datafile FileUpload(; accept = ".csv", label = "Data")
datafile === nothing ? md"Upload a file to begin." : CSV.read(datafile.path, DataFrame)accept filters the picker. It is a convenience, not a guarantee, so validate what you got. FileUpload is also one of the few write paths an app-mode visitor is allowed, which makes it the way a reader gets data into an app.
Picking on a figure
The Pick* controls make the figure itself the control: the reader clicks the plot instead of dragging a pair of x/y sliders, and the value arrives in the axis's own data coordinates.
They are declared without the figure and aimed at an axis afterwards with pick_on!:
@bind p PickPoint(; default = (-0.6, -0.9), snap = 0.05)
fig = Figure(); ax = Axis(fig[1, 1]); heatmap!(ax, xs, ys, Z)
scatter!(ax, [p.x], [p.y]) # the marker follows the bind
pick_on!(:p, fig, ax) # …and the click target follows the axis
figThat order is forced rather than stylistic. A figure that draws the pick reads the bind, so a control built from the figure — PickPoint(fig, ax) — would depend on a figure that depends on it. Declaring the control bare and aiming it afterwards is what breaks the cycle, and it means a re-run, a limit change or a resize simply re-aims it: the calibration travels with the figure it describes and cannot drift from it. Outside a harvesting eval (julia notebook.jl) pick_on! is a no-op, so the notebook still runs as a script.
Call pick_on! last. It measures where the axis is, so anything that changes the layout afterwards — a title, an axis label, a legend, another plot — moves the axis out from under that measurement and every click lands offset by however far it shifted. The failure is silent: the control still returns coordinates in range, they are simply the wrong ones. The trap worth naming is a title that reports the picked value, since that changes the layout on every pick; put such a readout in a markdown cell below the figure, where can interpolate it.
The mapping comes from the axis itself, so it is exact rather than assumed. An aspect = DataAspect() axis that letterboxes inside its layout cell reports the plotted area, not the cell; a log axis maps where its ticks are, not linearly between its endpoints. A reversed axis is mapped in drawing order, so a click lands on the side of the axis it was made on. An axis with no rectangular pixel→data mapping — a PolarAxis, or a three-dimensional Axis3 — is refused with an error rather than silently mapped, because a wrong rectangle returns plausible coordinates instead of failing.
Several picks can share one figure: each pick_on! describes its own axis, so two panels drive two different controls. A pick commits on release, never mid-drag — the control is drawn on the cell's output, and committing replaces that output, so a mid-drag commit would pull the figure out from under the gesture. It also means dragging out a box orders one recompute, not one per frame.
snap quantises the value to a grid measured from the axis floor. Aside from keeping long floats out of a readout, it is what makes a PickPoint work in a static export: a finite grid can be precomputed, so a click on an offline page lands on a position the export already has an answer for. Without it the value is continuous and the pick is live-only.
snapto answers a different question. Where snap says "anywhere, to this precision", snapto names the points that may be chosen and takes the nearest, so a click always resolves to a real candidate however far away it was pressed — there is no radius to miss. The candidates are drawn on the figure and the one under the pointer lights up, so the reader can see where a click will land before committing to it. Given both, snapto wins, being the more specific statement of the choice.
@bind sensor PickPoint(; snapto = [(s.x, s.y) for s in stations])It is also the better shape for a static export: the domain is the candidate set, so five stations are five entries, where the equivalent snap = 0.25 grid over the same axis is 625 and has to be checked against the replay cap. PickPath takes snapto too, choosing each of its points from the set; PickRegion does not, because "nearest candidate" for a box is a different operation than for a point.
Two details worth knowing. "Nearest" is measured across the axis, through its own scale, not in data units — on a log x spanning 1…10000 against a y of 0…1, raw distance would be decided entirely by x and the candidate chosen would not be the one under the cursor. And a candidate is returned exactly as written, without the clamp the other paths apply: you named the point, so a value outside the current limits is one the reader cannot click rather than one to be rewritten. A malformed candidate is an error where you wrote it, not a silent omission from the set.
Pair a pick with hidden when the figure is the only control you want on screen; otherwise the control strip shows a readout of the current value, since there is nothing to drag there.
Labelled options
Any option may be a bare value or a value => label pair. As soon as one is a pair, the bound variable is a Choice, and .value, .label and .index reach the parts. It prints and interpolates as its value, and when that value is a number, string or character it also compares, hashes and converts as its value — so it drops into arithmetic and dictionary keys unchanged.
For any other value type, Symbol most commonly, compare pick.value rather than pick:
@bind scheme Select([:qam => "QAM", :psk => "PSK"])
scheme.value === :qam # ✓
scheme == :qam # ✗ always falseChoice deliberately defines no mixed == against Symbol and no one-argument hash: those signatures invalidate roughly a thousand already-compiled methods when the package loads, which costs seconds on a notebook's first render. .value is also the only spelling that ever worked with === or isequal, so it is the one to reach for regardless of value type. MultiSelect and MultiCheckBox bind a Selection, an ordered read-only map of those pairs. Radio renders markdown and $math$ in its labels.
Button pairs with @onclick to run an action on click. TableSelect renders any slate_table-compatible data and binds the row you click; playhead is a driven control that receives an animation's current frame so another cell can react to playback.
Every widget takes a label keyword except Button, whose text is its first positional argument. The value reconciles across re-runs: re-running a bind cell updates the widget's range/options but keeps the user's current value (unless its type or domain changed).
MultiSelect is the compact dropdown, for a long option list; MultiCheckBox is the checkbox list, for a small discrete set.
Insert a control fast
Press ⌘K and type "bind" to insert any of these as a snippet — at the cursor of the selected code cell, or into a fresh cell.
Gallery
Each control as it renders in a cell.
Slider
Slider(0:100; default = 42, label = "samples")

NumberField
NumberField(0, 100, 12; label = "count")

Checkbox
Checkbox(true; label = "I agree")

Toggle
Toggle(true; label = "stream", on = "Live", off = "Paused")

TextField
TextField("Ada"; label = "name")

TextArea
TextArea("…"; label = "notes")

Select
Select(["red", "green", "blue"], "green"; label = "color") — the default is the second positional argument (as with Radio/MultiSelect); label is a keyword.

Radio
Radio(["S", "M", "L"], "M"; label = "size")

MultiSelect
MultiSelect(["x", "y", "z"], ["x", "z"]; label = "tags")

MultiCheckBox
MultiCheckBox(["a", "b", "c"], ["b"]; label = "flags")

ColorPicker
ColorPicker("#56d364"; label = "tint")

DateField
DateField("2026-06-05"; label = "date")

TimeField
TimeField("09:30"; label = "time")

Button
Button("Run")

Mixed cells
A cell can declare binds and run code. The control(s) render at the top and the cell's output below — useful for a self-contained "control + plot" unit.
@bind freq Slider(1:20)
using CairoMakie; set_theme!(theme_dark())
lines(0:0.01:1, sin.(2π*freq .* (0:0.01:1)))Control strips and the palette
A bound control can be surfaced in another cell's control strip — drag its grip into a code cell to place it near the output it drives (it can live in several cells at once; the variable stays single-sourced). Arrange controls into columns by dropping between them.
Open the 🎛 Controls palette (top bar or ⌘K) to see every @bind declared across the notebook, its live value, and where it's surfaced. Click a chip to jump to its defining cell; drag a chip into a cell to surface it; drop it back on the palette to remove it.

How it stays in sync
Changing a control posts the new value to its defining cell, which restales and recomputes the readers (see Reactive Cells). While you drag, updates are rate-limited and coalesced so the kernel isn't flooded; releasing flushes the final value. Every widget bound to the same variable — strip copies included — stays in lockstep.
Updating without recomputing
Reading a bound variable makes a cell a reader, so every change reruns it. For a cheap cell that is exactly right. For an expensive one — or a figure whose whole point is that it persists, like an interactive WebGL scene with a camera you have positioned — rebuilding on every tick is the wrong shape: the work is redone and the state is lost.
bind_observable(:name) gives you the control's value as an Observable instead. The name is quoted, so it is data rather than a read and the cell never restales; the value is pushed into the Observable and whatever is listening updates in place.
@bind decay Slider(0.0:0.05:1.5; default = 0.35)
let # runs once — `decay` appears only as a quoted name
d = bind_observable(:decay)
t = range(0, 4π; length = 400)
fig = Figure()
lines(fig[1, 1], t, lift(v -> @.(exp(-v * t) * sin(4t)), d))
fig
endThe Observable is cell-local: a fresh one per run, released when the cell reruns. That is what makes an ordinary lift on top of it safe — it dies with the cell, so nothing accumulates across reruns. An Observable you create yourself and keep alive across runs does not have that property, and listeners on it will stack.
Because the name is quoted there is no dependency edge, and cells in a batch evaluate in parallel — so a control declared in a different cell may not exist yet when the observing cell runs. Declare the @bind in the same cell as the figure to remove the race. It does not reintroduce the rerun: a control reruns its declaring cell only when the changed name is among that cell's reads, and a quoted name is not a read.
Controls drawn somewhere else
hidden(…) wraps a widget to say the notebook should not draw it: no row in its @bind cell, no entry in a surfaced strip, and it is not offered by the 🎛 palette.
@bind phase hidden(Slider(0.0:0.1:6.3))It is otherwise an ordinary control. It holds a value, coerces it, fires @onchange, feeds bind_observable, and remains a parameter in a static export. Only the chrome is suppressed.
This is for when something else is drawing the control — a widget inside a figure, a custom panel — and you want one control rather than two copies of it that can drift apart. Extensions reach the same controls through the execution context; see Writing an Extension.
In a static export
An exported HTML page has no Julia behind it, so by default a control renders as real markup and stays disabled. Marking the expression it drives with @replay makes it work anyway: the export computes every position the control can take and ships the results with the page. See Offline interactivity.
Custom widgets
Not enough with the built-ins? You can register your own widget type in JavaScript and bind it with @bind x custom_widget("kind") — see Front-end Extensions.