My feedback on the widgets API

Hey everyone!

I love what y’all are doing with widgets, it’s enabled me to build a really cool product for content modeling (Tapi) with a fraction of the work I’d have to put in to building my own infinite, collaborative canvas.

In this post, I’ll share my perceptions of how the widget API & DX could be better after working on it for almost 300h. Before I get into the nitty gritty of what I disliked, know that the amazing parts overshadow these. I’m a glad developer and have no regrets in choosing Figjam for my product :slight_smile:

Issues and oddities with the widgets API

These are from my own personal experience and I haven’t done much digging to investigate them. I may be mistaken in an issue or two :slight_smile:
There’s not much logic to the order here, I’m sorry about that :sweat_smile:

SVGs render funkily

Strokes don’t render well, lines ends are joined, I think. Instead, I have to outline them & convert into fills first.

When using masks, any path that comes after the mask definition will only render if it uses the mask itself:

<svg
  width="35"
  height="35"
  viewBox="0 0 35 35"
  fill="none"
  xmlns="http://www.w3.org/2000/svg"
>
  <!-- ✅ Renders -->
  <path
    fill-rule="evenodd"
    clip-rule="evenodd"
    d="M29.0514 14.1H25.9999V12.9H31.0999V18H29.8999V14.9486L20.9242 23.9243L20.0756 23.0758L29.0514 14.1ZM14.8999 14.9H23.9999V16.1H16.0999V27.9H27.8999V20H29.0999V29.1H14.8999V14.9Z"
    fill="#262F3D"
  />
  <mask
    id="mask0_63_3255"
    style="mask-type: alpha"
    maskUnits="userSpaceOnUse"
    x="4"
    y="3"
    width="20"
    height="19"
  >
    <path d="M14 14V22H4.5V3.5H24V14H14Z" fill="black" />
  </mask>
  <g mask="url(#mask0_63_3255)">
    <path
      d="M6.5 17.5H20.5M6.5 9.5H20.5M5.5 13.5H21.5M13.5 21.5C13.5 21.5 9 19.5 9 13.5C9 7.5 13.5 5.5 13.5 5.5M13.5 5.5C13.5 5.5 18 7.5 18 13.5C18 19.5 13.5 21.5 13.5 21.5M13.5 5.5V21.5M21.5 13.5C21.5 17.9183 17.9183 21.5 13.5 21.5C9.08172 21.5 5.5 17.9183 5.5 13.5C5.5 9.08172 9.08172 5.5 13.5 5.5C17.9183 5.5 21.5 9.08172 21.5 13.5Z"
      stroke="#262F3D"
      stroke-width="1.2"
    />
  </g>
  <!-- 🚨 Doesn't render -->
  <path
    fill-rule="evenodd"
    clip-rule="evenodd"
    d="M29.0514 14.1H25.9999V12.9H31.0999V18H29.8999V14.9486L20.9242 23.9243L20.0756 23.0758L29.0514 14.1ZM14.8999 14.9H23.9999V16.1H16.0999V27.9H27.8999V20H29.0999V29.1H14.8999V14.9Z"
    fill="#262F3D"
  />
</svg>

Not deal breakers, but gets in the way of an optimal workflow of copying from Figma design & dropping into my widget codebase.

Confusing widgetId

WidgetNode.widgetId (the global widget’s identification number in the Figma community) can get easily confused with figma.widget.useWidgetId() (the individual widget node’s ID)
Even after 200h+ working on my widget, I’m getting confused with all of the different IDs this brings to the table

Widgets change their widgetId when updated

When users update widgets to the latest version, Figma deletes the current one and creates a new instance for the new version, with a new ID. For reference, I’m talking about the below:

For projects using node IDs in their code for referencing across widgets, this behavior breaks the upgrade path. I tried creating my own workaround for this (which is super messy), but it’s hard to test and leads to all sorts of spaghetti logic in useEffects :grimacing:

Also, it’d be great if users could select multiple widgets and update all at once. Having to do so individually is tedious and makes it harder for existing users to adopt new versions.

(accessibility) Widget iframe keyboard focus & tabbing

If they’re editing a form and accidentally tab too much, they can’t go back into the frame with their keyboard because tabs work differently when the main canvas is focused. Ideally, Figma would trap tabs like we do in regular web dialogs (see the dialog element’s behavior).

Also, when opening a frame, Figjam could focus on the close button or an interactive element to make keyboard usage easier.

I saw these get in the way of users a couple of times during user interviews.

Publishing workflow gets in the way of businesses

Widgets must be published to the entire community in order to be accessed outside developers’ machines.

As a result, I had to make Tapi public in order to conduct user interviews, and now I can’t easily run feature betas and unlock specific behavior for specific users.

(plugin API) Creating styled & labeled connectors is hard

I have to load the fonts every time I use them, even when it’s Figma’s default font, Inter. And editing text is a pain:
- you can’t use hex colors;
- RGB isn’t base 255, it’s 1-0;
- you need to specify the range of your fill and can’t apply them text-wide;
- it isn’t clear what weights are available for which fonts

The result is a lot of verbosity to create a simple connector label:

await figma.loadFontAsync({ family: 'Inter', style: 'Bold' })
connector.text.fontName = {
  family: 'Inter',
  style: 'Bold',
}
connector.text.textCase = 'UPPER'
connector.text.letterSpacing = { unit: 'PIXELS', value: 0.5 }
connector.text.characters = typeStyle.label
connector.text.setRangeFills(0, connector.text.characters.length, [
  // Label in gray/800
  { type: 'SOLID', color: typeStyle.labelColor, opacity: 0.75 },
])

It’s impossible to set other widget’s data

In my widget, I have tons of features that require performing document-wide changes to all instances. However, widgetSyncedState is read-only, making it impossible to set other widget’s data.

I could set global plugin data with widgetNode.setPluginData and sync the individual widget once the user interacts with it (through useEffect). That’s complex to manage reliably, though, and leads to terrible UX as users won’t get immediate feedback and will need to interact with the widget for it to pick up the changes.

I could duplicate the node with a new state and delete the old, but then I’d need to update all references, which would require re-creating ALL widget instances. Imagine the performance and sync issues that arise from this :disappointed_relieved:

Creating a new widget is unnecessarily hard

The widgetNode.cloneWidget method requires you to erase all existing data from cloned widget if you want to start from scratch. This would be okay if it was easy to clean state keys in syncedStateOverrides, which doesn’t support null & undefined as values.

For textual/numeric state values, you pass an empty string or 0 - easy enough, although a bit bothersome.

But for synced maps, you can’t just pass an empty object {} else Figma will ignore it and instantiate the widget with the cloned node’s map state. Instead, you must pass an object with at least a single non-null property to it.

So to allow my widgets to create siblings, I had to add an invalid-signature property to the synced maps and run a clean-up function in the useEffect of that widget. The logic is kinda gross and hard to track, to be honest. Here’s a portion of it (missing the useEffect bit)

const newWidget = clonedWidget.cloneWidget(
  // (syncedStateOverrides)
  // Unfortunately we can't pass null or undefined to syncedStateOverrides, so we must manually overwrite it
  {
    label: startingModel.label || '',
    icon: startingModel.icon || ('' as any),
    modelId: startingModel.modelId || '',
    initializedAs: startingModel.initializedAs || {},
    nodeUid: newNodeUid,
    quantity: startingModel.quantity || ModelQuantity.collection,
    type: startingModel.type || ModelType.document,
  } as ModelConfig,
  
  // (syncedMapOverrides)
  {
    // Reset all attribute maps
    ...Object.keys(clonedWidget.widgetSyncedState)
      .filter((key) => key.startsWith('attribute'))
      .reduce((acc, key) => {
        return {
          ...acc,
          [key]:
            key === 'attributes'
              ? {
                  // For a reason I couldn't figure out, you can't set an empty object for the attributes synced map
                  // The widget gets rendered, but you can't interact with it
                  // The error: "Attempt to set a `nullopt` value on MultiplayerMap" in setWidgetSyncedState"
                  [INVALID_SIGNATURE_KEY]: {
                    invalid: true,
                  },
                }
              : {},
        }
      }, {}),
  
    // And add the starting attributes, if applicable
    ...startingAttributeDatas,
  },
)

Finally, placing the widget is kinda hard as you have to calculate a position where it won’t be overlapped :thinking:

Hard to do iframe window size management

I feel weird about setting a fixed pixel-based width & height because users’s OS zoom level varies. Apparently there’s no way to make them responsive to their content, and I can’t get the available screen size to size the frame accordingly.

As a result, I have to make all frames quite tiny to make room for zoomed-in and tiny monitors like mine.

The widget-app-template

It doesn’t teach us how to re-use code across UI & widget, which is essential.
Also, it could feature a multi-file boilerplate for the widget code. I initially thought you couldn’t even split that code.tsx file and was stuck trying to manage 900+ lines of a lot of logic. Now, just my widget has way over 3000 LOC spread across multiple files, I couldn’t do it in a single one.

You can’t access plugin data in the render method

As you can’t run the figma plugin API from the render method of widgets, you can’t access global plugin data, only widget-specific state.

This makes it super hard to configure UIs to respond to project configuration. Some examples of what I’d like to do with Tapi:
- Offer users the possibility to rename the labels in the widget according to their role (designers, content people and developers have different names for the same things)
- Split the content modeling workflow in multiple stages, and have all widgets reflect the currently selected stage - in the strategic stage, only models’ names would be available, for example.
- If they have a paid project, display access to the models store

I’d also love to show other widget instances’s data to allow quick actions like “connect to X”.

No way to expose actions on non-widget nodes

For example, I’d love to be able to offer users the ability to import a content model from selected sticky notes. Apparently, only traditional plugins have this right, and I’d need to create a separate plugin just to do that. As I don’t think forcing users to install 2 separate entities, and as I don’t want to go through two separate publishing processes, this is a feature I simply can’t build.

Would be lovely to have connection hooks

Stickable hooks are pretty cool and I’m looking for inspiration on how to use them, but I know for sure I’d use a hook that fires when the widget is connected to some other node.

In Tapi, it’d make content modeling much faster & intuitive as I’d be able to auto-create new reference attributes when users manually connect a model to another. Ideally, the hook would fire on both widgets being connected.

Conclusion

You’re off to a great start, and additions like the amazing Input give me hope you’re on the right track. A huge thank you to the whole team behind widgets & Figma in general!

The issues above don’t keep me from building something really compelling, and some forced me to get really creative about my implementation, which was fun.

If my product succeeds, in the long term I’ll be forced to leave Figjam, though. I’m very limited by the architecture of splitting widget & iframe code, and having access to plugin data only upon user interaction, tied to the canvas-only UI of widgets.

BUT I’m sure you’re aware of most of these and are making a conscious choice on what constitutes the ideal Figjam widget. I may be trying to overshoot, that’s all :slight_smile:

You can specify the fill through the fills property:

connector.text.fills = [
    {
        "type": "SOLID",
        "visible": true,
        "opacity": 1,
        "blendMode": "NORMAL",
        "color": {"r": 0, "g": 0, "b": 0}
    }
]

To get all available fonts, you can use the listAvailableFontsAsync() method and then filter by fontName.family.

Through the viewport property, you can get the dimensions of the viewport and apply these values in the showUI() method. And for custom iFrame window sizes, you can use the resize() method.

I don’t quite understand why this is not possible. In my widget (How many Sticky Notes?) I use PluginData to sort the list of authors, and when the button is clicked, I get the sort data so that the order of the list does not change when rendering.

1 Like

Thanks for the great feedback @Henrique_Doro! Thanks for leaving such detailed feedback on the api. Please keep it coming because for real feedback like this plays a huge part in how we change the api in the future!

SVGs render funkily

Thanks for the report here! The <SVG> component uses the same codepath that importing an svg into Figma does, so this is probably a general bug that we can look into!

Confusing widgetId

I 100% agree and this was for sure an oversight on our part I also still get these mixed up :sweat_smile:. Do you have any suggestions on naming? I was thinking of deprecating useWidgetId and renaming to useWidgetNodeId, but if you have any ideas I’d love to hear them.

Publishing workflow gets in the way of businesses

I agree that now that people have actually built some widgets it would be amazing to have some sort system similar to test flight where you can distribute a widget without having to add it to the community (we have had this exact same problem when publishing our own widgets like the Photobooth, Asana, and Jira widgets).

(plugin API) Creating styled & labeled connectors is hard

This is really good feedback. I think with the widget api we really streamlined the process of setting properties (ex you can use hex codes for colors, pass a fontweight, and not have to worry about loading fonts). It might be worth it to see if we can port any of those improvements back to the plugin api.

It’s impossible to set other widget’s data

We actually just added the setWidgetSyncedState method last week to address this problem! Take it for a spin and let us know if you have any feedback on it!

Hard to do iframe window size management

I agree it would be great if we had a way to do this automatically. I’ve done this before using a ResizeObserver. But I’ll see if we can add an example to the docs!

The widget-app-template

This is good feedback that we should add this to the sample

You can’t access plugin data in the render method

Yeah this is a limitation of how we made widgets work with our multiplayer system. All data that is used in render needs to exist inside of synced state. At the moment the only work around is to set that data in synced state when a user interacts with the widget. In the future we might be able to let your widget code run when other parts of the document change!

No way to expose actions on non-widget nodes

I’m not sure if this is what you are asking for, but you can have your widget read the current selection when you run it, so you could have the user select the stickies and then press an import button on your widget. I agree that it would be cool to have something like plugin relaunch buttons for widgets though.

Would be lovely to have connection hooks

I 100% agree that this would be amazing

Again thank you so much for the feedback!

2 Likes