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, // makes this block reusable by other blocks
window: {
width: 420,
height: 560,
},
inputs: { ... },
bind(inputs, node) { ... },
});
Rules:
inputsis requiredkeyis required whenexpose: truewindowis optional.widthandheightcan be provided independently. Values above720pxwidth or900pxheight are clamped.bindis optional
Errors:
block.define() was never called— script ran but never calledblock.defineblock.define({ expose: true }) requires a non-empty key
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 — useful when options depend on another input:
featuredFlight: {
type: "select",
label: "Featured flight",
options(inputs) {
return (inputs.flights || []).map(function(flight, index) {
return {
label: String(flight.flightNo || ("Flight " + (index + 1))),
value: String(index),
};
});
},
default: "0",
}
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) {
node.findChild("FEATURED_SLOT").setBlockValue(value);
},
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 exposed block.
flights: {
type: "collection",
label: "Flights",
blockKey: "flight",
default: [],
view: "table", // optional: "list" | "table"
includeInputs: ["airline", "flightNo"], // optional
write(node, value) {
node.findChild("FLIGHTS_SLOT").setBlockValues(Array.isArray(value) ? value : []);
},
initial(node) {
return node.findChild("FLIGHTS_SLOT").getBlockValues();
},
}
default must be an array of objects. write and initial are optional.
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[]
findOne with a predicate:
var icon = node.findOne(function(n) { return n.name.startsWith("_icon"); });
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() // returns object — reads stored block value
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" }) // targets a block-powered child instance
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].
Reusable Blocks
Expose a block once with key and expose: true:
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: [],
},
},
});
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
},
});