Documentation
Three hooks for code-driven Figma components.
Attach inputs to a component, hydrate them from canvas with initial(), push changes through write(), and keep derived state in bind().
Block API
Block attaches code-driven inputs to a Figma component. The script runs inside the plugin and syncs values to the canvas through three hooks: write, initial, and bind.
Mental Model
block.define({
inputs: {
myInput: {
type: "text",
label: "My Input",
default: "Hello",
// Runs when this input's value changes → push value to canvas
write(node, value) { ... },
// Runs once on open → pull current canvas state into this input
initial(node) { return ... },
},
},
// Runs when any input changes → cross-input logic and derived state
bind(inputs, node) { ... },
});
Every API method exists in service of one of these three slots. NodeProxy read methods belong in initial (and conditionally in write/bind). NodeProxy write methods belong in write and bind. Traversal methods work everywhere.
block.define
block.define({
key: "flight", // required when expose: true
expose: true, // false hides this block from the plugin UI (see Headless Collections)
window: {
width: 420,
height: 560,
},
inputs: { ... },
bind(inputs, node) { ... },
});
Rules:
inputsis requiredkeyis required whenexpose: trueorexpose: falsewindowis optional.widthandheightcan be provided independently. Values above720pxwidth or900pxheight are clamped.bindis optional- Each script may contain at most one main
block.define()(withoutexpose: false)
expose
| Value | Behaviour |
|---|---|
true | Block appears in the plugin UI and is registered in the global registry so other components can reference it via blockKey |
false | Block is hidden from the plugin UI. It stays local to the script and is available as a blockKey target only within the same script (see Headless Collections) |
| omitted | Block appears in the plugin UI but is not registered globally — cannot be referenced via blockKey from other scripts |
Errors:
block.define() was never called— script ran but never calledblock.defineblock.define({ expose: true }) requires a non-empty keyblock.define({ expose: false }) requires a non-empty keyOnly one main block.define() is allowed per scriptDuplicate local block key "X"— twoexpose: falseblocks in the same script share the same keyNo main block.define() found — all defines have expose: false— everyblock.define()call hasexpose: false
Inputs
Each input is a plain object with at minimum type, label, and default.
inputs: {
myInput: {
type: "text", // required
label: "Title", // required, non-empty string
default: "Hello", // required, must match the type
write(node, value) { ... }, // optional
initial(node) { return ... }, // optional
},
}
Errors shared by all input types:
inputs.X.type is requiredinputs.X.type "foo" is invalidinputs.X.label must be a non-empty stringinputs.X.default is requiredinputs.X.initial must be a functioninputs.X.write must be a functioninputs.X.read is not supported. Use inputs.X.initial instead
text
title: {
type: "text",
label: "Title",
default: "Hello",
}
Default must be a string.
number
count: {
type: "number",
label: "Count",
default: 3,
min: 0, // optional
max: 12, // optional
}
min and max are optional. Default must be a valid number within range if min/max are set.
Error: inputs.X.default must be a valid number
slider
progress: {
type: "slider",
label: "Progress",
default: 60,
min: 0, // optional
max: 100, // optional
step: 1, // optional, must be a positive number
}
min, max, and step are optional. Default must be a valid value within range.
Errors:
inputs.X.step must be a positive numberinputs.X.default must be a valid slider value
boolean
enabled: {
type: "boolean",
label: "Enabled",
default: true,
}
Default must be true or false.
Error: inputs.X.default must be a boolean
select
variant: {
type: "select",
label: "Variant",
options: ["Primary", "Secondary", "Danger"],
default: "Primary",
}
options may also be a function that receives current input values and the block's node — useful when options depend on another input or on the target node:
featuredFlight: {
type: "select",
label: "Featured flight",
options(inputs, node) {
return (inputs.flights || []).map(function(flight, index) {
return {
label: String(flight.flightNo || ("Flight " + (index + 1))),
value: String(index),
};
});
},
default: "0",
}
The node argument is a NodeProxy for the block's target node, identical to the one available in initial and write. It allows options to be derived from node properties (e.g. child names, fill count). Note: node is only populated at apply-time; during static validation it is a no-op proxy.
Errors:
inputs.X.options must contain at least one valueinputs.X.default must be one of its options
block
Use type: "block" to embed the inputs of another exposed block.
featuredFlight: {
type: "block",
label: "Featured flight",
blockKey: "flight",
default: {},
includeInputs: ["airline", "flightNo"], // optional — limits which child inputs are shown
write(node, value) {
var slot = node.findChild("FEATURED_SLOT");
slot.setBlockValue("airline", value.airline);
slot.setBlockValue("flightNo", value.flightNo);
},
initial(node) {
return node.findChild("FEATURED_SLOT").getBlockValue();
},
}
default must be an object. write and initial are optional.
Errors:
inputs.X.blockKey "flight" could not be resolvedCyclic block reference detected for blockKey "flight"inputs.X.default must be an objectinputs.X.includeInputs must be an array of stringsinputs.X.includeInputs contains unknown child input "X"
collection
Use type: "collection" to manage a list of items from another block.
flights: {
type: "collection",
label: "Flights",
blockKey: "flight",
default: [],
view: "table", // optional: "list" | "table"
includeInputs: ["airline", "flightNo"], // optional
write(node, value, event) {
node.findChild("FLIGHTS_SLOT").setBlockValues(Array.isArray(value) ? value : []);
},
initial(node) {
return node.findChild("FLIGHTS_SLOT").getBlockValues();
},
}
write event
Collection write receives an optional third argument event that describes what changed. It is undefined on first write or when no change is detected.
write(node, value, event) {
// event.type — "add" | "remove" | "edit" | "initial"
// event.index — position of the changed row in the array
// event.oldValue — previous row data, null for "add"
// event.newValue — new row data, null for "remove"
}
event.type | event.oldValue | event.newValue | When |
|---|---|---|---|
"initial" | null | null | Plugin opened or no change detected — seed canvas from full value |
"add" | null | new row | A new row was added |
"remove" | removed row | null | A row was deleted |
"edit" | previous row | updated row | A row's fields were changed |
event handlers
Instead of a switch on event.type, the event exposes handler methods. Each handler only fires when the event matches:
write(node, value, event) {
// shared logic runs here regardless of event type
var day = event && event.newValue ? Number(event.newValue.day) : null;
event.onAdd(function() { ... });
event.onEdit(function() { ... });
event.onRemove(function() { ... });
event.onInitial(function() { ... });
}
event.onAdd, event.onEdit, event.onRemove, and event.onInitial each accept a callback that fires only when event.type matches. Logic placed outside these handlers runs unconditionally, making it easy to share setup code across all event types.
Use event with findBlock to locate and update the matching canvas slot without relying on layer names:
write(node, value, event) {
if (!event) {
node.findChild("FLIGHTS_SLOT").setBlockValues(Array.isArray(value) ? value : []);
return;
}
if (event.type === "add") {
// new row at event.index — handle however fits your layout
}
if (event.type === "remove") {
var removed = node.findChild("FLIGHTS_SLOT").findBlock("flight", event.oldValue);
removed.setVisible(false);
}
if (event.type === "edit") {
var slot = node.findChild("FLIGHTS_SLOT").findBlock("flight", event.oldValue);
var updated = value[event.index];
slot.setBlockValue("airline", updated.airline);
slot.setBlockValue("flightNo", updated.flightNo);
}
}
default must be an array of objects. write and initial are optional.
When blockKey references a local expose: false block defined in the same script, the collection is automatically headless — it stores its rows in plugin data on the parent node and never creates or manages canvas instances. See Headless Collections.
Errors:
inputs.X.view must be "list" or "table"inputs.X.default must be an array of objects
write(node, value)
Called every time this input's value changes. Use it for one-to-one canvas bindings — one input maps to exactly one canvas target.
title: {
type: "text",
label: "Title",
default: "Hello",
write(node, value) {
node.findChild("_title").setText(String(value));
},
}
Rules:
nodeis the root instancevalueis the new input value — always coerce it to the expected type (String(value),Number(value), etc.)- Each canvas target can only be owned by one
write(). Two inputs cannot write the same layer. bind()cannot write to a target already owned by anywrite().
Available NodeProxy methods: all traversal, all read, all write.
Error: Duplicate write target: text node "_title". Already owned by input "otherInput".
initial(node)
Called once when the editor opens. Use it to read the current canvas state and seed the input with the real value.
title: {
type: "text",
label: "Title",
default: "Hello",
initial(node) {
return node.findChild("_title").getText();
},
}
Rules:
- Must return a value. If
initialreturnsundefined, or a value that fails type validation, the plugin falls back to the last saved value, then todefault. - Runs once on open. After that, the UI state is the source of truth for the session.
- If the returned value differs from the last saved value, the plugin treats the input as drifted (canvas was edited directly while the plugin was closed) and automatically runs
bind()to re-sync any derived state. - Manual canvas edits while the editor is already open are not pulled back into the UI and may be overwritten by later input changes.
Available NodeProxy methods: all traversal, all read. Write methods are available but should not be used in initial.
bind(inputs, node)
Called every time any input changes. Use it for cross-input logic, derived state, and anything that depends on more than one input.
bind(inputs, node) {
node.findChild("Badge").setVisible(Boolean(inputs.title) && !inputs.disabled);
}
Rules:
inputscontains the current value of every input- Cannot write to any canvas target already owned by an input's
write() - A canvas target that is only written in
bind()should have noinitial()—bind()owns it entirely as a pure output
Available NodeProxy methods: all traversal, all read, all write.
Error: Duplicate write target: visibility on "Badge". Already owned by input "title". Remove one writer or move this logic fully into bind().
Derived values
A layer whose value is always computed from inputs should have no initial() and live entirely in bind():
block.define({
inputs: {
value: {
type: "number",
label: "Value",
default: 0,
initial(node) {
return Number(node.findChild("_value").getText());
},
write(node, value) {
node.findChild("_value").setText(String(value));
},
},
},
bind(inputs, node) {
// _calculatedValue is derived — bind() owns it, no initial()
node.findChild("_calculatedValue").setText(String(inputs.value + 2));
},
});
When _value is edited directly on canvas while the plugin is closed, drift is detected on next open and bind() runs automatically to bring _calculatedValue back in sync.
NodeProxy
Traversal — available in write, initial, and bind
node.findChild("Layer Name") // first direct or nested child matching the name
node.findOne("Layer Name") // first descendant at any depth matching the name
node.findOne(fn) // first descendant matching a predicate function
node.findById("123:456") // descendant by Figma node ID
node.findAllByName("Layer Name") // all descendants matching the name
node.findAll("TEXT") // all descendants of a given Figma node type
node.children // direct children as NodeProxy[]
node.findBlock("blockKey", { field: value }) // first matching descendant — any depth
node.findAllBlocks("blockKey", { field: value }) // all matching descendants — any depth
findBlock returns the first matching descendant; findAllBlocks returns all of them as an array. Both search the entire subtree regardless of nesting depth — frames, groups, and other containers between the root and the target are transparent. The match object is optional; omit it to find by block key alone.
// find the first seat-row where row === 4
var row = node.findBlock("seat-row", { row: 4 });
// find all seat-rows
var rows = node.findAllBlocks("seat-row");
// find all booked seats
var booked = node.findAllBlocks("seat", { status: "booked" });
Returns a no-op proxy (like a missing node) if no match is found — safe to call write methods on without a null check.
findOne with a predicate:
var icon = node.findOne(function(n) { return n.name.startsWith("_icon"); });
optional callback
All find methods accept an optional callback as their last argument. The callback receives the found node and is called immediately — for single-result methods when a match is found, and for multi-result methods once per match.
// without callback — traditional form, returns the node
var badge = node.findChild("Badge");
badge.setVisible(true);
// with callback — find and act in one step
node.findChild("Badge", function(badge) {
badge.setVisible(true);
});
// findBlock with callback
node.findBlock("day-event", { day: 3 }, function(dayNode) {
dayNode.setBlockValue("day", 3);
});
// findAllBlocks with callback only — no match filter
node.findAllBlocks("day-event", function(dayNode) {
dayNode.setBlockValue("events", []);
});
// findAllBlocks with match + callback
node.findAllBlocks("day-event", { day: 3 }, function(dayNode) {
dayNode.setBlockValue("events", []);
});
Both forms are equivalent — the callback is a convenience that avoids storing the result in a variable when you only need to act on it immediately.
Properties — available in write, initial, and bind
node.id // Figma node ID string
node.name // layer name
node.type // Figma node type: "INSTANCE", "TEXT", "FRAME", etc.
Useful for conditional logic:
bind(inputs, node) {
node.children.forEach(function(child) {
if (child.type === "INSTANCE") {
child.setProperty("Variant", String(inputs.variant));
}
});
}
Read — available in write, initial, and bind
node.getText() // returns string — only meaningful on TEXT nodes
node.getVisible() // returns boolean
node.getOpacity() // returns number 0–1
node.getProperty("Variant") // returns string | boolean | number | undefined — only on INSTANCE nodes
node.getBlockValue("row") // returns the value for a specific key
node.getBlockValue() // returns all stored values as an object
node.getBlockValues() // returns array — reads stored collection values
Read methods are most commonly used in initial() to seed inputs from canvas state, but can also be used in write() or bind() for conditional logic.
Write — available in write and bind
node.setProperty("Variant", "Primary") // only on INSTANCE nodes
node.setProperties({ "Variant": "Primary", "Size": "Medium" }) // only on INSTANCE nodes
node.setText("Hello") // only on TEXT nodes
node.setVisible(true)
node.setOpacity(0.4) // clamped to 0–1
node.setBlockValue("airline", "Emirates") // sets one key — merges with existing stored values
node.setBlockValues([ // targets a collection host
{ airline: "Emirates" },
{ airline: "Qatar" },
])
setProperty, setProperties — silently no-op on non-INSTANCE nodes.
setText — silently no-op on non-TEXT nodes.
setOpacity — value is clamped to [0, 1].
setBlockValue(key, value) — sets one key in the node's stored block data, merging with whatever is already there. Other keys are preserved. Mirrors the pattern of setProperty.
node.collection(key)
Mutates a collection stored inside a node's block value. Use it to add, remove, or update individual items in a child node's collection without rewriting the entire array.
node.collection("events").add(item) // appends item to the array
node.collection("events").remove(item) // removes the first item matching all fields of item
node.collection("events").update(oldItem, patch) // finds the first item matching oldItem and merges patch into it
remove and update match by comparing each field of the provided object against the stored items using deep equality. Keys present in the match object but absent in a stored item are skipped.
update accepts a partial patch — only the fields you provide are changed, the rest are preserved:
// change only the name field
node.collection("events").update({ name: "Standup" }, { name: "All Hands" });
Commonly used inside a findBlock callback to update a specific child node's collection:
write(node, value, event) {
event.onAdd(function() {
node.findBlock("day-event", { day: Number(event.newValue.day) }, function(dayNode) {
dayNode.collection("events").add(event.newValue);
});
});
event.onRemove(function() {
node.findBlock("day-event", { day: Number(event.oldValue.day) }, function(dayNode) {
dayNode.collection("events").remove(event.oldValue);
});
});
event.onEdit(function() {
var oldDay = Number(event.oldValue.day);
var newDay = Number(event.newValue.day);
if (oldDay !== newDay) {
node.findBlock("day-event", { day: oldDay }, function(dayNode) {
dayNode.collection("events").remove(event.oldValue);
});
node.findBlock("day-event", { day: newDay }, function(dayNode) {
dayNode.collection("events").add(event.newValue);
});
} else {
node.findBlock("day-event", { day: newDay }, function(dayNode) {
dayNode.collection("events").update(event.oldValue, event.newValue);
});
}
});
}
block.utils
Utility functions available inside every block script via block.utils.
block.utils.clamp(value, min, max) // clamps value to [min, max]
block.utils.lerp(a, b, t) // linear interpolation between a and b by t (0–1)
block.utils.parseList(str) // splits a comma-separated string into a trimmed, filtered array
Example:
bind(inputs, node) {
var clamped = block.utils.clamp(Number(inputs.progress), 0, 100);
var parts = block.utils.parseList(inputs.tags); // "A, B, C" → ["A", "B", "C"]
node.findChild("_bar").setOpacity(block.utils.lerp(0, 1, clamped / 100));
}
Reusable Blocks
Expose a block once with key and expose: true. This registers it in the global registry so other components can reference it via blockKey, and shows it in the plugin UI when that component is selected:
block.define({
key: "flight",
expose: true,
inputs: {
airline: { type: "text", label: "Airline", default: "" },
flightNo: { type: "text", label: "Flight #", default: "" },
},
});
Reference it from another block using type: "block" or type: "collection":
block.define({
inputs: {
featuredFlight: {
type: "block",
label: "Featured flight",
blockKey: "flight",
default: {},
},
flights: {
type: "collection",
label: "Flights",
blockKey: "flight",
view: "table",
default: [],
},
},
});
Headless Collections
A headless collection stores its own data without being bound to component instances on the canvas. Use it when you need a top-level data source on a parent component that drives children through custom logic — for example, an events list on a calendar that distributes entries to the correct day blocks.
Define a schema block with expose: false in the same script as the main block. The collection referencing it is automatically treated as headless:
// Schema block — hidden from plugin UI, local to this script only
block.define({
key: "event",
expose: false,
inputs: {
month: { type: "text", label: "Month", default: "" },
day: { type: "number", label: "Day", default: 1 },
name: { type: "text", label: "Name", default: "" },
},
});
// Main calendar block
block.define({
inputs: {
events: {
type: "collection",
label: "Events",
blockKey: "event", // resolves to the local schema above
view: "table",
default: [],
// initial and write are optional — use them to read/write canvas state
initial(node) {
// Read existing events from canvas if needed
},
write(node, value) {
// Distribute rows to the correct day blocks
},
},
},
});
How it works:
expose: falseblocks are invisible in the plugin UI and not added to the global registry. They exist only as a local schema definition within the script.- When a collection's
blockKeyresolves to a local block, the plugin marks it as headless internally — no canvas instances are created or managed. - Row data is stored in plugin data on the parent component node.
initialandwritehooks work exactly as in regular collections. Omitting them means rows are purely stored in plugin data with no canvas side-effects.
Compared to a regular collection:
| Regular collection | Headless collection | |
|---|---|---|
| Schema source | Derived from a globally exposed block (expose: true) | Defined locally with expose: false |
| Canvas binding | Creates and manages component instances | None — data lives in plugin data only |
initial / write | Optional | Optional |
| UI | Same (table / list view, add / remove rows) | Same |
Ownership Rules
Block validates write ownership at definition load time.
- Each canvas target may only be written by one
write()across all inputs bind()may not write to any target already owned by an input'swrite()- Violations are caught before the block runs
Valid — title.write() owns _title, bind() owns Badge:
block.define({
inputs: {
title: {
type: "text",
label: "Title",
default: "Hello",
write(node, value) { node.findChild("_title").setText(String(value)); },
initial(node) { return node.findChild("_title").getText(); },
},
},
bind(inputs, node) {
node.findChild("Badge").setVisible(Boolean(inputs.title));
},
});
Invalid — both title.write() and bind() write _title:
block.define({
inputs: {
title: {
type: "text",
label: "Title",
default: "Hello",
write(node, value) { node.findChild("_title").setText(String(value)); },
},
},
bind(inputs, node) {
node.findChild("_title").setText(String(inputs.title)); // error: already owned by title.write
},
});