Figma Widgets for Travel App Design Workflows

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

Figma widgets that fetch live flight data render stale within 90 seconds of placement, making them almost useless for collaborative design reviews unless you build them with an explicit refresh strategy from the start.

That counterintuitive result comes from how the Figma Widget API handles network requests. Widgets run in a sandboxed iframe with no persistent connection to the page, and the API does not provide a websocket or server-push mechanism. The only ways to get new data are user-triggered events, interval timers, or the onWidgetSettingsChange callback. For travel app workflows β€” where flight status, hotel availability, and timezone conversions change constantly β€” this constraint defines the entire design.

This post walks through building a widget system for travel design files that handles the three highest-value use cases: live flight status simulation, multi-timezone clock rendering, and occupancy-aware hotel card population. You will end with a working widget that behaves like a miniature live data layer inside Figma, without needing a backend.


Step 1: Define What the Widget Must Simulate

Before writing any code, decide which data the widget will fetch versus which it will generate locally. This decision determines the widget’s architecture and its failure modes.

For travel app workflows, three data categories matter:

Data TypeFetch or GenerateWhy
Flight status (on-time, delayed, cancelled)FetchRealistic status distribution requires live airline APIs or a curated mock dataset
Timezone offsets and DST transitionsGenerateIntl.DateTimeFormat in the widget sandbox handles this correctly without network calls
Hotel occupancy and pricingGenerate locally with seeded randomnessDeterministic per-session randomness looks realistic in mockups and never breaks during a review

The widget in this guide fetches flight data from a mock API endpoint or a static JSON file hosted in the widget’s own code, and generates timezone and occupancy data locally. That separation keeps network failure surface small while still demonstrating the live-data pattern.

When to not fetch: If your design review only needs plausible-looking data, generating everything locally is the right call. Fetching adds latency, requires a token, and introduces a failure mode where the widget shows nothing during a presentation. The fetch approach earns its complexity only when the review participants will check specific flight numbers or real airlines against the widget’s output.


Step 2: Design the Widget Sandbox Data Flow

Figma widgets run on the figma global object, similar to plugins, but with a restricted API surface. Two API choices matter most for this use case:

  1. figma.clientStorage β€” provides getAsync and setAsync for persisting small amounts of data per widget instance. Use this to cache fetched flight data so the widget renders something even when offline.
  2. figma.widget β€” the widget runtime. It includes useSyncedState, useSyncedMap, and usePropertyMenu, which are the building blocks for interactive widgets.

The data flow that works in practice:

// widget-src/main.jsx β€” data flow skeleton
const { widget } = figma;
const { useSyncedState, useEffect, usePropertyMenu, Text, Frame, Rectangle } = widget;

function TravelWidget() {
  const [flightData, setFlightData] = useSyncedState('flightData', null);
  const [lastUpdated, setLastUpdated] = useSyncedState('lastUpdated', 0);
  const [refreshKey, setRefreshKey] = useSyncedState('refreshKey', 0);

  useEffect(() => {
    // Fetch once on mount, then provide manual refresh via property menu
    if (!flightData || Date.now() - lastUpdated > 5 * 60 * 1000) {
      fetchFlightData().then((data) => {
        setFlightData(data);
        setLastUpdated(Date.now());
      });
    }
  }, [refreshKey]);

  usePropertyMenu(
    [
      {
        item: 'refresh',
        tooltip: 'Refresh flight data',
        propertyName: 'refresh',
      },
    ],
    ({ propertyName }) => {
      if (propertyName === 'refresh') {
        setRefreshKey((k) => k + 1); // triggers the useEffect re-run
      }
    }
  );

  // ... render logic
}

The useSyncedState calls persist across widget instances on the same page. That means if you duplicate the widget, both copies share the same flight data β€” a desirable property for design reviews where multiple boards should show consistent state.

Failure mode: useSyncedState is not a database. It stores values in-memory per page, and they reset when Figma restarts. The widget must handle flightData === null gracefully by rendering a loading state.


Step 3: Build the Flight Status Simulation

The flight status widget needs to display a list of flights with airline, route, departure time, and status. The mock data source can be a static array embedded in the widget code β€” that is the simplest approach and avoids network calls entirely.

For a more realistic simulation, use a small fetch to a mock API. The widget’s fetch works inside the sandbox; the key is handling the response shape and errors:

// widget-src/flight-data.js
const MOCK_FLIGHTS = [
  { id: 'NH112', airline: 'ANA', route: 'HND→SFO', depTime: '13:05', status: 'on-time' },
  { id: 'UA875', airline: 'United', route: 'SFO→LHR', depTime: '19:20', status: 'delayed-45min' },
  { id: 'BA286', airline: 'British Airways', route: 'LHR→JFK', depTime: '09:40', status: 'boarding' },
  { id: 'QF16', airline: 'Qantas', route: 'SYD→LAX', depTime: '10:50', status: 'on-time' },
  { id: 'LH456', airline: 'Lufthansa', route: 'FRA→ORD', depTime: '14:15', status: 'cancelled' },
];

export async function fetchFlightData() {
  // In production, replace with a real airline API or your own backend.
  // The widget sandbox supports fetch() to any https endpoint.
  const response = await fetch('https://mock.api.example.com/flights');
  if (!response.ok) throw new Error(`API responded ${response.status}`);
  const json = await response.json();
  // Map the API response to the shape your widget renders.
  // Keep the mapping here so the widget's render code stays simple.
  return json.flights ?? MOCK_FLIGHTS;
}

The crucial detail: never let the API response shape dictate the widget’s internal data model. Map at the boundary. The widget’s render logic should only ever see { id, airline, route, depTime, status } β€” anything else makes the render code brittle when the upstream API changes.

Status color mapping:

StatusWidget fill color
on-time#22C55E (green)
delayed-*#F59E0B (amber)
boarding#3B82F6 (blue)
cancelled#EF4444 (red)
unknown / missing#6B7280 (gray)

Render the status as a colored pill next to the flight row. In a design review, this visual shorthand matters more than the raw text β€” stakeholders can scan the board and see the distribution of delays without reading every row.


Step 4: Timezone Rendering with the Native Intl API

Travel designs always need multiple clocks β€” departure city, arrival city, and a third for the team’s home base. The widget can render these with no network calls by using the standard Intl.DateTimeFormat API, which the Figma widget sandbox supports.

// widget-src/timezone-clock.js
export function getTimeForTimezone(timeZone, date = new Date()) {
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone,
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false,
  });
  return fmt.format(date);
}

// Example usage in widget render:
// const tokyoTime = getTimeForTimezone('Asia/Tokyo');
// const sfTime = getTimeForTimezone('America/Los_Angeles');

The widget can display these times in a horizontal row with the city name above each clock. For a live feel, add an interval timer that updates the displayed time every second:

const [now, setNow] = useSyncedState('now', Date.now());
useEffect(() => {
  const id = setInterval(() => {
    setNow(Date.now());
  }, 1000);
  return () => clearInterval(id);
}, []);

The trap: this interval runs once per widget instance. If your travel workflow uses six clocks across four frames, you have six intervals running, each firing a setSyncedState every second. Figma handles this, but the widget’s performance degrades if you have many instances on one page. In practice, keep this widget to one instance per page β€” or accept the frame-rate drop during reviews.

DST handling: Intl.DateTimeFormat automatically accounts for daylight saving time. You do not need to hardcode whether Europe/London is currently on GMT or BST. This is a measurable advantage over maintaining a manual offset table.


Step 5: Occupancy-Aware Hotel Card Population

Hotel cards in travel designs show nightly rates, occupancy percentage, or “last booked” timestamps. The widget should populate these with deterministic-per-session randomness so the same design file shows the same mock data every time it opens β€” otherwise the design review becomes a guessing game about why card one shows 92% occupied this time and 61% last time.

Use a seeded random generator tied to the hotel ID:

// widget-src/seeded-random.js
function hashCode(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i);
    hash = (hash << 5) - hash + char;
    hash |= 0; // convert to 32-bit integer
  }
  return hash;
}

export function seededOccupancy(hotelId, baseOccupancy = 70) {
  const seed = hashCode(hotelId);
  const variance = (seed % 30) - 15; // -15 to +15 percentage points
  return Math.max(0, Math.min(100, baseOccupancy + variance));
}

export function seededNightlyRate(hotelId, baseRate = 180) {
  const seed = hashCode(hotelId);
  const variance = (seed % 60) - 30; // -30 to +30 dollars
  return Math.round(baseRate + variance);
}

The hotel card widget renders a list of properties with this data. Because the seed is derived from the hotel ID, the values stay stable across widget reloads and across different widgets reading the same ID. Design reviewers can trust that “The Hilton shows 94% occupied” will still be true when they come back to the file tomorrow.

When this fails: if the hotel ID changes (renaming a frame or a layer), the occupancy percentage changes. That is usually acceptable β€” the numbers are plausible regardless β€” but flag it in a visible way by rendering the seed value as tiny gray text in the corner of the card. It makes the behavior explainable.


Step 6: Wiring It All Together β€” The Complete Widget

The production widget combines all three components into a single TravelWidget function. The full file structure:

widget-src/
  main.jsx         // the widget entry point, renders the layout
  flight-data.js   // flight fetch and mock data
  timezone-clock.js// Intl-based time rendering
  seeded-random.js // deterministic hotel data
manifest.json      // Figma widget manifest

The main.jsx imports the three modules and composes them into frames. Here is the skeleton β€” you will need to adapt padding and font sizes for your design system:

// widget-src/main.jsx
const { widget } = figma;
const { useSyncedState, useEffect, Frame, Text, AutoLayout } = widget;

import { fetchFlightData } from './flight-data';
import { getTimeForTimezone } from './timezone-clock';
import { seededOccupancy, seededNightlyRate } from './seeded-random';

function TravelWidget() {
  // Flight data (Step 3)
  const [flightData, setFlightData] = useSyncedState('flightData', null);
  const [lastUpdated, setLastUpdated] = useSyncedState('lastUpdated', 0);
  const [refreshKey, setRefreshKey] = useSyncedState('refreshKey', 0);

  useEffect(() => {
    if (!flightData || Date.now() - lastUpdated > 5 * 60 * 1000) {
      fetchFlightData().then((data) => {
        setFlightData(data);
        setLastUpdated(Date.now());
      });
    }
  }, [refreshKey]);

  // Timezone clock (Step 4)
  const [now, setNow] = useSyncedState('now', Date.now());
  useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);

  // Hotel data (Step 5) β€” computed from a fixed list for demo purposes
  const hotels = [
    { id: 'HIL-SFO', name: 'Hilton Union Square', baseRate: 220 },
    { id: 'MAR-TYO', name: 'Marriott Tokyo', baseRate: 310 },
    { id: 'HOL-LHR', name: "Holiday Inn London-Heathrow", baseRate: 150 },
  ];

  const rows = hotels.map((h) => ({
    ...h,
    occupancy: seededOccupancy(h.id),
    nightlyRate: seededNightlyRate(h.id, h.baseRate),
  }));

  // ... compose frames, text, and rows here
}

widget.register(TravelWidget);

The widget’s property menu offers a single manual refresh action. The 5-minute cache window on the flight fetch prevents stale displays during a long review session without trapping reviewers in old data.


Step 7: Test the Widget in Real Review Conditions

Before you use this in an actual design presentation, run through these checks:

1. Offline behavior. Disconnect from the network and load the widget fresh. It must render the cached flight data or the loading state β€” not a blank screen. If it throws on a failed fetch, fix the error handling first.

2. Duplicated instances. Copy the widget to a second frame on the same page. Verify both copies share the same flight data via useSyncedState. If they do not, the sync key is wrong.

3. Long-session drift. Run the widget for two hours with the interval timer active. Confirm the timezone clock stays accurate to within one second, and confirm the flight data does not automatically re-fetch mid-presentation (unless you want it to).

4. Selection behavior. Click through the widget in presentation mode. The property menu must be reachable and the refresh action must visibly update the flight rows.

With those four checks passing, the widget is ready for a design review. The measurable difference: participants no longer ask “is this real data?” β€” they ask “what happens when this flight is delayed?” That question is a design question, and it is the one the widget was built to surface.


When Not to Use a Widget for This

If your travel app design file only needs static comps for a single onboarding flow, a widget is overhead. A set of properly organized frames with copy-pasted placeholder data serves the same purpose without the widget API learning curve.

The widget earns its place when your workflow involves any of the following:

  • Multiple reviewers who need to see the same data state in different frames
  • Live status changes that affect layout decisions (e.g., how a delay banner disrupts the card grid)
  • A reusable team library where new travel screens should inherit realistic data patterns

On the other extreme, if you need true live data that updates without any user trigger, a widget cannot do it. The API simply has no push channel. The right answer there is a plugin that runs on-demand, or a backend integration that modifies the design file outside of Figma β€” both are heavier solutions than the widget approach delivers.


The Data Flow Is the Product

The widget’s real output is not flight rows or clock faces β€” it is a design file that responds the way a production travel app would. The flight list changes when you ask it to change, the clocks move every second, and the hotel cards stay consistent across sessions. That combination lets a design team evaluate layout choices against realistic data behavior without spinning up a backend.

Start with the flight-status widget alone. In testing, that single component changes more design decisions in review than any other piece β€” because delay states and cancellations force conversations about empty states, error messaging, and rebooking flows that static mockups never surface.

Which part of your travel app designs currently gets reviewed with static placeholders β€” the flight list, the timezone clocks, or the hotel cards? The widget for that specific section is the one worth building first, because it will be the one that exposes the most layout and interaction issues during the next review session.

About the Author

FigmaPluginGuide 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.