A color contrast checker plugin is a Figma plugin that reads the foreground and background colors of selected nodes, computes their contrast ratio using the WCAG relative luminance formula, and reports whether that ratio passes or fails the required threshold for the text size in question. The math is fixed and public, the inputs are colors Figma already exposes through its API, and the output is a single number compared against a known threshold β which is exactly why it makes a good first plugin project. There is no backend, no OAuth, no remote data, and no ambiguity about what “correct” means.
The catch is that a first-time Figma plugin author usually runs into the same three walls in the same order: the plugin’s two-runtime architecture (main code vs ui code), the difference between WCAG AA and the older WCAG 2.1 contrast formula, and the fact that a node’s “background” is not a property you can read directly. This guide walks through all three with copy-pasteable code and names each API method as it comes up.
Beginner vs Advanced: What Separates the Two
Before writing any code, it helps to be explicit about what a beginner build looks like versus what a more advanced implementation involves. A beginner version is a working plugin that produces correct results for the common case. An advanced version handles the edges. Most published contrast checkers are somewhere in between.
| Concern | Beginner build | Advanced build |
|---|---|---|
| Input | The single node selected by the user | Whole-page traversal, nested frames, auto-layout stacks |
| Contrast formula | WCAG 2.x relative luminance ratio | WCAG 2.x ratio plus APCA (WCAG 3 draft) side-by-side reporting |
| Color handling | fills[0].color on both node and figma.currentPage | Blend modes, opacity multiplication, gradient stops, image fills |
| Background detection | Assumes the parent frame’s fill is the background | Walks ancestors until a non-NONE fill is found, or gives up |
| Threshold logic | One pass/fail check against 4.5:1 | Font-size and font-weight aware thresholds (large text is 3:1) |
| Output | figma.notify() with the ratio | UI panel with per-node rows, severity coloring, a “fix” suggestion |
| Persistence | None | Saves last-run options with figma.clientStorage |
The beginner column is achievable in a few hours with no prior Figma plugin experience. The advanced column is weeks of iteration. This guide covers the beginner column in full and points at where each advanced concern would slot in.
Step 1: Scaffold the Plugin Without the Template Generator
Figma offers a plugin template through the desktop app’s “Create new plugin” flow, but for a first plugin, building the manifest by hand teaches the structure faster and produces less code to delete later. A contrast checker needs exactly three files:
contrast-checker/
βββ manifest.json
βββ code.js
βββ ui.html
The manifest declares the plugin to Figma. It needs a name, an ID (which Figma assigns on first load, or you can leave a placeholder and let the plugin menu fill it in), a main entry pointing at the code-side script, and a ui entry pointing at the HTML:
{
"name": "Contrast Checker",
"id": "0000000000000000000",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html",
"editorType": ["figma"],
"networkAccess": {
"allowedDomains": []
}
}
The networkAccess block is not optional in current plugin versions. Leaving it out or omitting allowedDomains causes Figma to reject the manifest silently. An empty array is correct here because a contrast checker makes no network calls β which is one of the small wins of picking this as a first project.
To load the plugin for development: open the Figma desktop app, go to Plugins β Development β Import plugin from manifestβ¦, and select the manifest.json file. After the first import, the plugin appears under Plugins β Development and can be re-run from there. Editing files and re-running the plugin picks up the changes without re-importing.
Step 2: Understand the Two-Runtime Split
This is the single concept that trips up most first-time plugin authors, so it’s worth stating plainly. A Figma plugin runs in two separate JavaScript environments:
code.js(the “main” or “sandbox” thread) β has access to thefigmaglobal object. Can read the document, inspect nodes, change properties. Has no DOM, nowindow, nodocument.ui.html(the “iframe”) β has a full browser environment:document,fetch,localStorage. Has no access tofigma.
The two communicate with postMessage in both directions. This split exists for security: the iframe can’t touch the document, and the sandbox can’t touch the network without permission. The practical consequence is that if you want the UI to display a contrast ratio, code.js has to compute it and send it across; the UI can’t read it directly.
For a contrast checker, this split maps cleanly onto responsibilities:
code.jsreads node fills, computes the contrast ratio, decides pass/fail against thresholdsui.htmlrenders the result and wires up any controls the user interacts with
Step 3: Read Foreground and Background Colors
The foreground color is easy. For a text node, it’s the first solid fill:
const node = figma.currentPage.selection[0];
if (!node || !('fills' in node) || !Array.isArray(node.fills) || node.fills.length === 0) {
figma.notify('Select a text or shape node with a solid fill.');
figma.closePlugin();
return;
}
const fill = node.fills[0];
if (fill.type !== 'SOLID') {
figma.notify('Only solid fills are supported. Gradient and image fills are not.');
figma.closePlugin();
return;
}
The fill.color object has r, g, and b properties in the range 0β1, not 0β255. This trips people up. If you log a red fill expecting { r: 255, g: 0, b: 0 }, you’ll see { r: 1, g: 0, b: 0 } instead.
The background color is harder, because there is no node.background property. Background is a visual concept, not a semantic one. To find it, the plugin has to walk up the node’s ancestor chain and take the first ancestor with a non-NONE, non-transparent solid fill:
function findBackgroundColor(startNode) {
let current = startNode.parent;
while (current) {
if ('fills' in current && Array.isArray(current.fills) && current.fills.length > 0) {
const f = current.fills[0];
if (f.type === 'SOLID' && f.visible !== false && f.opacity !== 0) {
return f.color;
}
}
current = current.parent;
}
// Reached the page. Figma's default page background is white.
return { r: 1, g: 1, b: 1 };
}
Two failure modes live inside this function. First, if the user’s text sits directly on the canvas with no containing frame, the loop reaches figma.currentPage (whose parent is null) and falls through to the white default. Second, if a frame in the chain has a fill but with opacity: 0, that frame is transparent in practice but not in the fills array β hence the explicit opacity check. Gradient and image fills are skipped entirely, and if the first fill found is one of those, the walk keeps going, which is wrong but is the pragmatic tradeoff a beginner build makes.
A more accurate implementation would sample the rendered pixel underneath the text node, which requires reading from figma.currentPage after a figma.viewport.screenshot() call. That’s an advanced-build concern.
Step 4: Compute Contrast with the WCAG Formula
WCAG 2.x defines contrast ratio as (L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter color and L2 is the luminance of the darker one. The luminance formula itself is where most first attempts go subtly wrong, because it applies a gamma correction (a piecewise linear-then-exponential curve) to each channel before weighting:
function relativeLuminance({ r, g, b }) {
const channel = (c) => {
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function contrastRatio(fg, bg) {
const l1 = relativeLuminance(fg);
const l2 = relativeLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
The 0.03928 and 12.92 constants are not arbitrary β they define the threshold below which the sRGB curve is treated as linear. Skipping the gamma correction and computing luminance as a plain weighted average massively overstates contrast for mid-tones, so a plugin that gets this wrong will report failing pairs as passing.
Thresholds for WCAG 2.1 AA:
| Text size | Threshold |
|---|---|
| Normal text | 4.5:1 |
| Large text (β₯18pt, or β₯14pt bold) | 3:1 |
| Non-text UI components | 3:1 |
WCAG 2.2 added a AAA-level “Focus Appearance” criterion but did not change the AA contrast thresholds. If you want to support the newer APCA model (still a WCAG 3 draft at time of writing), that is a separate, more complex calculation and belongs in an advanced build.
Step 5: Wire Up the UI
The UI file is plain HTML with a <script> tag. It receives a message from the main thread and updates the DOM. A minimal version looks like this:
<!DOCTYPE html>
<html>
<body style="font: 12px sans-serif; padding: 12px; width: 240px;">
<div id="result">Waiting for selectionβ¦</div>
<script>
window.onmessage = (event) => {
const msg = event.data.pluginMessage;
if (!msg || msg.type !== 'contrast-report') return;
const { ratio, passesAA, passesAAA } = msg;
const el = document.getElementById('result');
el.innerHTML = `
<p style="font-size: 20px; font-weight: 600;">${ratio.toFixed(2)}:1</p>
<p>AA: ${passesAA ? 'β
Pass' : 'β Fail'}</p>
<p>AAA: ${passesAAA ? 'β
Pass' : 'β Fail'}</p>
`;
};
</script>
</body>
</html>
Then in code.js, after computing the ratio, send it across:
figma.showUI(__html__, { width: 260, height: 160 });
const ratio = contrastRatio(fill.color, findBackgroundColor(node));
figma.ui.postMessage({
type: 'contrast-report',
ratio,
passesAA: ratio >= 4.5,
passesAAA: ratio >= 7,
});
Note the two-directional pattern: figma.showUI() runs in code.js, and figma.ui.postMessage() sends data to the iframe. The iframe can send data back with parent.postMessage({ pluginMessage: {...} }, '*'), which is how you’d wire up a “Re-check” button later.
Step 6: Handle Non-Solid Fills and Missing Backgrounds Gracefully
A first version that assumes every text node has a solid fill and a solid-fill parent will crash on real documents the moment a user selects a text layer placed over an image, or sitting on a frame with a gradient background, or inside an auto-layout stack with no fill at all.
The correct pattern is to detect the unsupported case and tell the user, rather than guess:
if (fill.type !== 'SOLID') {
figma.ui.postMessage({
type: 'contrast-report',
unsupported: true,
reason: 'Text fill is a gradient or image. Only solid fills are supported.'
});
return;
}
The UI then renders an explanatory message instead of a nonsense ratio. This is the right tradeoff for a beginner build: refusing to answer is better than answering incorrectly, because a user who sees “5.2:1 β AA Pass” on a gradient-filled button will trust it, and the underlying computation was meaningless.
If you want to support gradient fills accurately, you need to sample the pixel color at the text node’s center point, which requires reading from the rendered canvas β again, an advanced concern.
Step 7: Test the Plugin Against the Ground Truth
The single most useful verification step for a contrast checker is to compare its output against a known-good external calculator. The WebAIM Contrast Checker is a browser-based tool that accepts two hex colors and returns a ratio. Run a handful of pairs through both:
#000000on#FFFFFFβ should be exactly 21:1#767676on#FFFFFFβ should be just over 4.5:1 (this is the classic “barely passes AA” gray)#FFFFFFon#FFFFFFβ should be exactly 1:1
If your plugin returns 21:1, ~4.54:1, and 1:1 for those three, the math is correct. If it returns something else, the luminance function is almost always the culprit β check the gamma correction constants first.
A secondary test is to place a text node on a parent frame with a mid-gray fill, select the text, run the plugin, and confirm the number changes when you change the frame’s fill. If it doesn’t change, the ancestor walk is broken and the background is coming from the wrong place.
Common Failure Modes First-Time Authors Hit
Beyond the ones already named, three others come up repeatedly:
The plugin closes before the UI renders. figma.closePlugin() called too early kills the iframe before postMessage delivers. If your plugin flashes a UI and vanishes, look for a closePlugin() call before the postMessage.
The figma object is undefined inside the UI. This always means UI-only code was placed in code.js, or vice versa. The two runtimes don’t share scope. If you’re writing a helper that both halves need, duplicate it or pass the result across postMessage.
The plugin works on the first run but not on the second. This is usually stale UI state β the iframe persists between runs without reloading, so any accumulated state (a counter, a cached ratio) carries over. Reset state on onmessage receipt, or use figma.showUI() on every run to force a fresh iframe.
What a Beginner Build Should Stop Short Of
Three advanced concerns are worth explicitly deferring until the beginner version is working end-to-end:
Whole-page scanning. Iterating figma.currentPage.findAll() and checking every text node sounds appealing but multiplies the failure surface β nodes without fills, nodes inside components, nodes with mixed fills (figma.mixed sentinel values), and so on. Run the plugin on a user selection first.
Auto-fix. A plugin that suggests the nearest passing color requires color-space traversal and a heuristic for what “nearest” means β HSL distance, Lab distance, or perceptual. None is obviously correct, and a wrong suggestion undermines trust in the whole tool.
APCA. The newer contrast model produces very different results from WCAG 2.x for some pairs. Reporting both side by side is honest but confusing for a first release. Pick one, label it clearly, and stick with it.
The reason to defer is not that these are hard to code β some are quite short β but that they change what the plugin’s output means. A single-node, WCAG-2.x-only, read-only checker answers one question with one clear answer. Adding any of the three above dilutes that.
Quick Reference: Where Each Piece Lives
| Concern | File | Key API |
|---|---|---|
| Declare plugin to Figma | manifest.json | main, ui, networkAccess |
| Read selected node | code.js | figma.currentPage.selection |
| Read node fill color | code.js | node.fills[0].color (r/g/b 0β1) |
| Find background color | code.js | Walk node.parent chain, check fills |
| Compute ratio | code.js | WCAG relative luminance + (L1+0.05)/(L2+0.05) |
| Open the panel | code.js | figma.showUI(__html__, {...}) |
| Send data to panel | code.js | figma.ui.postMessage({...}) |
| Render result | ui.html | window.onmessage handler |
| Notify on bad input | code.js | figma.notify() |
If you want to extend the plugin next, the highest-leverage addition is a “check all text in this frame” button that runs the same contrast logic over frame.findAll(n => n.type === 'TEXT'). That converts the tool from a spot-checker into a small audit utility without requiring any new API surface β just a loop and a different message shape back to the UI. Everything else on the advanced list chains off that one extension, which is why it’s the natural next step after the beginner build is stable.