Block/API Reference

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().

inputswrite()initial()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:

  • inputs is required
  • key is required when expose: true or expose: false
  • window is optional. width and height can be provided independently. Values above 720px width or 900px height are clamped.
  • bind is optional
  • Each script may contain at most one main block.define() (without expose: false)

expose

ValueBehaviour
trueBlock appears in the plugin UI and is registered in the global registry so other components can reference it via blockKey
falseBlock 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)
omittedBlock 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 called block.define
  • block.define({ expose: true }) requires a non-empty key
  • block.define({ expose: false }) requires a non-empty key
  • Only one main block.define() is allowed per script
  • Duplicate local block key "X" — two expose: false blocks in the same script share the same key
  • No main block.define() found — all defines have expose: false — every block.define() call has expose: 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 required
  • inputs.X.type "foo" is invalid
  • inputs.X.label must be a non-empty string
  • inputs.X.default is required
  • inputs.X.initial must be a function
  • inputs.X.write must be a function
  • inputs.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 number
  • inputs.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 value
  • inputs.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 resolved
  • Cyclic block reference detected for blockKey "flight"
  • inputs.X.default must be an object
  • inputs.X.includeInputs must be an array of strings
  • inputs.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.typeevent.oldValueevent.newValueWhen
"initial"nullnullPlugin opened or no change detected — seed canvas from full value
"add"nullnew rowA new row was added
"remove"removed rownullA row was deleted
"edit"previous rowupdated rowA 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:

  • node is the root instance
  • value is 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 any write().

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 initial returns undefined, or a value that fails type validation, the plugin falls back to the last saved value, then to default.
  • 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:

  • inputs contains 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 no initial()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: false blocks 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 blockKey resolves 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.
  • initial and write hooks 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 collectionHeadless collection
Schema sourceDerived from a globally exposed block (expose: true)Defined locally with expose: false
Canvas bindingCreates and manages component instancesNone — data lives in plugin data only
initial / writeOptionalOptional
UISame (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's write()
  • Violations are caught before the block runs

Validtitle.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
  },
});