Figma Plugin Onboarding: Designing the First-Run Experience That Reduces Churn

JP
Jordan Pham
UX/UI Designer & Plugin Developer | 7+ Years Experience

The median Figma plugin loses nearly two-thirds of its users within the first five minutes of installation. When I pulled the analytics for a plugin I maintain — a data-visualization tool with a moderately complex setup — the number was worse: 68% of users who installed the plugin never completed a single successful operation. They opened the plugin, stared at an empty canvas and a floating panel with twelve controls, and closed it. Most never came back.

The counterintuitive part: the plugin’s functionality wasn’t the problem. Users who made it through the first successful data import rated the tool highly and became repeat users. The problem was entirely in the gap between installation and first success. Closing that gap required treating the first-run experience as a technical problem with measurable boundaries, not a copywriting exercise.


The Case Study: A Plugin with a Configuration Problem

The plugin in question — let’s call it GridMetrics — takes a CSV or JSON dataset and renders it as a configurable grid visualization on the canvas. The power is in the configuration: column mappings, aggregation functions, color scales. The failure was that configuration was exposed all at once before the user had seen a single successful render.

The original flow was a single panel with:

  1. A file upload button
  2. A dropdown for choosing the data source type
  3. A data preview table
  4. Five configuration sections (columns, aggregation, filtering, sorting, color)
  5. A “Generate Grid” button at the bottom

The analytics dashboard showed the drop-off points. Heatmaps of panel interactions revealed that most users never scrolled past the file upload step, and among those who did, only 40% reached the Generate button. The plugin was functioning as designed — the code did what the UI promised. The onboarding was failing.


Step One: Instrumenting the First-Run Path

Before changing anything, I added event tracking to the plugin. Figma plugins run in a sandboxed iframe, so you can’t use window.fetch to post events directly — you need to pass messages through the Figma API to the plugin’s main thread, which runs with Node.js access.

The tracking setup looked like this:

// In the UI iframe
export function trackEvent(eventName: string, properties?: Record<string, unknown>) {
  parent.postMessage(
    {
      pluginMessage: {
        type: 'track',
        payload: {
          event: eventName,
          properties,
          timestamp: Date.now(),
        },
      },
    },
    '*'
  );
}

// In the plugin main thread
figma.ui.onmessage = (msg) => {
  if (msg.type === 'track') {
    // Use a background HTTP request - allowed from the main thread
    fetch('https://analytics.example.com/event', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        event: msg.payload.event,
        properties: {
          ...msg.payload.properties,
          pluginId: figma.manifest.id,
          userId: figma.currentUser?.id,
        },
      }),
    }).catch(() => {
      // Analytics failures must never break the plugin
    });
  }
};

The critical design decision here: analytics failures were silently swallowed. A dropped tracking event was acceptable — a broken plugin was not. The catch block with an empty handler ensured that network hiccups in the analytics endpoint never propagated errors back into the plugin’s UI.

With tracking in place for three weeks, the data told a clear story:

EventCompletion Rate
Panel opened100%
Uploaded a data file71%
Previewed data58%
Modified at least one config option33%
Clicked “Generate Grid”32%
Ran a successful generation31%

Two findings shaped the redesign. First, the biggest drop — 13 percentage points — happened between uploading a file and seeing the preview. Users uploaded, saw nothing happen for a moment, and assumed the plugin was broken. Second, the gap between modifying a config option and generating was tiny, which meant that the configuration UI itself wasn’t the barrier — the barrier was getting users through the first two steps at all.


Step Two: The Two-Phase Onboarding Architecture

The redesign split the flow into two distinct phases, each with its own minimal UI: the Setup Phase and the Configuration Phase. The guiding principle was progressive disclosure implemented as a state machine, not as a design pattern.

The state machine was the technical foundation. Instead of a single panel with conditional rendering, the plugin now tracked a runState that could be 'empty', 'data-loaded', 'generated', or 'configured'. The UI rendered completely different components for each state, and crucially, the state was persisted so that returning users skipped directly to the last state they reached.

What the Setup Phase contains:

The user is shown only two things: a large drop zone for their data file and a single button for “Load Sample Data.” That’s it. No configuration visible, no dropdowns, no preview table. The phase’s only job is to get a dataset into the plugin’s memory.

What the Configuration Phase contains:

Only after a dataset loads successfully does the configuration UI appear. And even then, the configuration appears incrementally — the first visible panel shows just the “Columns” mapping, with a live preview of the grid rendering alongside it. Additional configuration sections (aggregation, filtering, color) are collapsed into an “Advanced” accordion that users can expand as needed.

The rationale from the data: users who modified at least one configuration option almost always generated the grid. The problem was that too many users never reached the configuration because they were overwhelmed before the data even loaded.


Step Three: The State Persistence Implementation

Figma plugins have a built-in mechanism for storing plugin-scoped data: figma.clientStorage. It’s an async key-value store that survives plugin restarts. This became the backbone of the onboarding state machine.

// State persistence for onboarding progress
const ONBOARDING_STORAGE_KEY = 'onboarding_state_v2';

interface OnboardingState {
  runState: 'empty' | 'data-loaded' | 'generated' | 'configured';
  lastDataHash?: string;
  lastConfig?: GridConfig;
  firstRunAt?: number;
  completedOnboardingAt?: number;
}

async function loadOnboardingState(): Promise<OnboardingState | null> {
  try {
    return await figma.clientStorage.getAsync(ONBOARDING_STORAGE_KEY);
  } catch {
    return null;
  }
}

async function saveOnboardingState(state: OnboardingState) {
  try {
    await figma.clientStorage.setAsync(ONBOARDING_STORAGE_KEY, state);
  } catch (error) {
    // Storage quota exceeded - clear old state and retry once
    await figma.clientStorage.deleteAsync(ONBOARDING_STORAGE_KEY);
    await figma.clientStorage.setAsync(ONBOARDING_STORAGE_KEY, state);
  }
}

The storage logic handles a specific failure mode: Figma’s clientStorage has a quota limit, and plugins that store too much data have their writes rejected. The catch block deletes any existing state and retries once, which trades durability of old data for the reliability of the current write. If the retry also fails, the error propagates and the plugin falls back to the empty state — the safest default since it requires the user to re-upload their data, which is recoverable.

The more interesting persistence decision was what not to store. Storing the actual dataset in clientStorage would let users skip the upload step entirely, but datasets can be large, and the storage quota is shared across all plugins. Storing a hash of the dataset instead — lastDataHash — lets the plugin detect when a user re-opens with a different dataset without keeping the old data around.


Step Four: Contextual Guidance Instead of Tooltips

The original plugin had a separate “Help” tab in the panel with a 500-word explanation of every configuration option. The analytics showed that tab was visited by 4% of users, and of those, none completed more than one configuration section after reading it. Tooltips and help tabs were the wrong delivery mechanism for guidance — they separated the explanation from the action.

The replacement was contextual guidance embedded directly into the workflow. Three specific implementations:

Inline validation messages that explain what to do next:

When a user uploads a CSV file, the plugin parses it and immediately displays one of three messages in the UI, not in a toast or a modal:

  • If the file has no header row: “We couldn’t detect column names in this file. The first row will be treated as data. To fix this, re-export your CSV with the ‘Include header row’ option checked in your spreadsheet tool.”
  • If the file is empty: “This file contains no data. Check that you haven’t selected an empty sheet.”
  • If the file parses successfully: “Loaded 1,247 rows with 8 columns. Mapping columns now.”

The third message is the key one. It tells users the plugin did something — an explicit state change — and tells them what happens next. The original failure mode was silence: users uploaded a file, saw no immediate change, and assumed a crash.

Progressive configuration with the “recommended” path:

The configuration phase reveals the aggregation and filter sections only after the user has mapped their columns, and it pre-selects a recommended default aggregation (“Sum” for numeric columns, “Count” for text columns). The pre-selection means a user who maps columns and clicks “Generate” gets a useful result without touching any other control. Users who want control can expand the advanced sections — but the default path is complete with three clicks after the data loads.

Error recovery that tells the user what failed and what to do:

The old plugin showed a generic “Failed to generate grid” error when a column mapping referenced a nonexistent column name. The new version shows:

Column "revenue_q1" not found in your data.
Available columns: date, region, product, units, revenue_q2, profit

Your mapping references a column named "revenue_q1", but your data has "revenue_q2". This may be a typo. Update the mapping below, or re-upload a file with the correct column name.

The error names the exact mismatch, shows what’s available, and offers two concrete next actions. There’s no “try again” button — that pattern would silently repeat the same failure.


Step Five: The Verify Loop — Measuring the Redesign’s Impact

The redesigned onboarding shipped as version 2.0, and the analytics ran for the same three-week period. The comparison was stark:

MetricBeforeAfterChange
File upload completion71%89%+18 pts
Preview reached58%85%+27 pts
First config modification33%72%+39 pts
Successful generation31%64%+33 pts
Return within 7 days12%38%+26 pts

The most telling improvement was the gap between “uploaded” and “generated.” Before, the drop from upload to generation was 40 percentage points — users uploaded, then stalled. After, the drop was 25 points, and the biggest remaining drop was not in the first-run flow at all, but in users who closed the panel mid-configuration without generating. The remaining churn is a different problem — task abandonment rather than onboarding failure.


The Failure Modes the Redesign Introduced

Every change created new edge cases worth knowing about. Three stood out in testing:

State persistence caused stale configuration to resurface. When a user returned to the plugin after a week away, the persisted state loaded their old config — including the dataset hash — but the dataset itself was gone, since we never stored full data. The plugin correctly showed the “data-loaded” state with a config referencing data that no longer existed. The fix was a guard in the load path:

const state = await loadOnboardingState();
if (state.runState === 'data-loaded' && !state.lastDataHash) {
  // Stale state - dataset was never persisted, reset to 'empty'
  await saveOnboardingState({ runState: 'empty' });
  return;
}

This catches the specific inconsistency where a state claims data exists but the hash is missing — which only happens when the state was saved with data but the data was never stored, a bug that crept in during an intermediate build.

The forced “Load Sample Data” button created a false success path. Users who clicked the sample data button got through the Setup Phase without understanding what they’d done — the sample data loaded, preview appeared, and a fraction of those users generated a grid using the sample data, then never swapped in their own file. For this plugin, that’s a false success: the user completed the flow but wasn’t using their own data. The mitigation was adding a distinct visual border and label (“Sample data — replace with your own file”) that persisted until a real file was uploaded.

The single-session bias was real. Returning users who had successfully generated a grid before were forced through the same two-phase flow on every new visit, even though their configuration was persisted. They didn’t need onboarding — they needed the full power UI immediately. The fix was a third state, 'configured', which loads the full configuration panel directly, bypassing the Setup and Configuration phases for users who have completed onboarding at least once.


When This Approach Stops Being the Right One

This design works for plugins where the first successful operation requires data input and configuration. It would be the wrong architecture for plugins whose core interaction is a single click — an icon picker, a simple shape generator, a color palette extractor. For those, any onboarding flow at all is overhead. The state machine adds complexity that buys nothing when the path from open to success is already three seconds.

The second limitation is the cost of building the two-phase architecture. The redesign took roughly 40 hours of focused engineering — the state machine, the persistence layer, the new UI components, the analytics instrumentation, and the migration path for users on the old version. If your plugin is a side project with a handful of users, the ROI calculation is different than for a growing commercial plugin.

The third boundary: this approach optimizes for the first-run completion rate, not for long-term feature discovery. Users who skip the advanced configuration sections early tend not to discover them later — the analytics show only 22% of returning users ever expand the advanced accordion. That’s a discovery problem the onboarding redesign did not solve. A different mechanism — periodic contextual prompts after successful generations — would be needed to address it.


The Measurable Outcome

The first-run abandonment rate dropped from 68% to 23% — a 45-point improvement. But the sharper number is the return rate: 38% of new users came back within a week, up from 12%. Users who didn’t return were mostly users who completed the flow but didn’t need the plugin again — a different metric entirely from users who never reached first success.

The onboarding redesign did not make the plugin’s core functionality easier to use. It made the first five minutes of using it match what the plugin does. The configuration UI, the data processing engine, and the grid rendering algorithm were nearly untouched. What changed was the sequence and context in which they were presented — and that was a technical problem, not a design problem.

If you track first-run events in your plugin, run this diagnostic: what percentage of users who open your panel reach a state where your plugin has produced an output they can see with their own eyes? If that number is below 60%, your onboarding is the bottleneck — not your feature set.


About the Author

Jordan Pham is a UX/UI designer and Figma plugin developer with 7 years of design experience and several published plugins on the Figma Community, used by thousands of designers.