Published on

Creating a Rotating Knob Figma Plugin


Link to plugin

GitHub Repo

Figma - Rotating Knobs

Whilst designing UI’s for Kontakt I found process for building custom knobs in Kontakt is to import an image containing every frame of rotation of a knob, stacked. This is a little frustrating to manually do, and the accepted solution appears to be to use the knobman web tool to handle the process, but isn’t easy for more complicated designs, as well as being a pretty 1998-feeling website. I started creating my knobs in Figma, with all of the rest of the design, but the frame process became a bit of a slog. I’d create the knob, and then add it to a frame with an auto layout. Then I could duplicate the knob and rotate it, letting the auto layout handle the stacking. This works, but is arduous - for a couple of reasons. In lots of cases, you don’t want the entire knob to rotate - for example, if shadow is applied to the knob, rotating the entire knob will rotate the shadow, and as light source isn’t changing, it looks odd. So, you only really want to rotate the indicator on the knob, however if the indicator is a separate element, then you need to make it pivot around the centre point of the knob itself. To achieve this, I created an invisible copy of the main circle on the knob and grouped it and the indicator. Then I could rotate this group and it would pivot the indicator no problem. Using the properties editor instead of the mouse was quicker and more precise for making frames, so I utilised that, and built my first Figma knob. 55 frames long, and I knew I didn’t want to do it again for different knobs down the road.

Plugins

So I decided to look into programming Figma plugins. There’s an easy boilerplate, installable through the Desktop app, that initialises a new plugin project with TypeScript. Installing dependencies and type definitions is enough to get going building a new plugin. Once edited, the .ts file needs to be compiled, and then the Figma Desktop app will be able to find it and run the plugin.

I began by making a super simple plugin that just set up page templates, containing pages for briefs, typography, colours, components etc. In a few minutes, I had my very first Figma plugin. Pretty neat. So the next step was to learn about interacting with the document. This is kind of tricky, and seems like there’s not tons of info around yet on the nuances of it, so plenty of time reading the docs was required. It is straightforward to clone a selection, allowing the user to select their knob, run the plugin, and it can create the copies. I appended each copy to a frame which had the auto layout turned on, so they began to stack neatly. Then I just needed to figure out the rotation.

Rotation

By default, groupNodes in Figma have a rotation property that can be set, but the point of rotation is not the center of the group. Instead it rotates around the relative origin for the selection. For a 90°90\degree rotation, the graph would look like so:

Grsphical example of rotation

The docs say when rotating about the central point, use the relativeTransform property, and pass it an Affine Transformation that handles the rotation. They also provide the formula for rotation with an Affine Transformation:

[cos(θ)sin(θ)0sin(θ)cos(θ)0]\begin{bmatrix} cos(\theta) & sin(\theta) & 0 \\ -sin(\theta) & cos(\theta) & 0 \end{bmatrix}

Because my knowledge of matrices and trig was pretty rusty, I went back to learn how this matrix is derived (https://www.youtube.com/watch?v=OYuoPTRVzxY).

This achieves the same result as setting the rotation property, rotating it around the wrong point. So in order to do the rotation around the central point, you have to do something a bit funky. You need to first translate the point from the original location to the (0,0) point, apply the normal rotation formula, and then translate it back by the opposite vector used for the initial translation. The Figma docs shows the following matrix for performing a translation:

[10tx01ty]\begin{bmatrix} 1 & 0 & tx \\ 0 & 1 & ty \end{bmatrix}

Where tx=translation in x axistx = translation\ in\ x\ axis and ty=translation in y axisty = translation\ in\ y\ axis. My first thought was that I could apply the translation as the relativeTransform first, then set the rotation after, followed by the final translation. However, because we are simply setting a property of the target, the first translation is just overwritten as soon as you set the rotation. You need to multiply the translation by the rotation, and then the result by the final translation to combine all steps into one Affine Transformation.

[10tx01ty]×[cos(θ)sin(θ)0sin(θ)cos(θ)0]×[10tx01ty]\begin{bmatrix} 1 & 0 & -tx \\ 0 & 1 & -ty \\ \end{bmatrix} \times \begin{bmatrix} cos(\theta) & sin(\theta) & 0 \\ -sin(\theta) & cos(\theta) & 0 \end{bmatrix} \times \begin{bmatrix} 1 & 0 & tx \\ 0 & 1 & ty \\ \end{bmatrix}

Digging back over matrix multiplication, I filled several pages of a notebook before arriving at an answer and finding that my transformation was skewing the target, not just rotating. Solving gave me the full transformation matrix that I needed to use to perform rotation.

[cos(θ)sin(θ)txcos(θ)+tysin(θ)txsin(θ)cos(θ)txsin(θ)+tycos(θ)ty]\begin{bmatrix} cos(\theta) & sin(\theta) & txcos(\theta) + tysin(\theta) - tx \\ -sin(\theta) & cos(\theta) & -txsin(\theta) + tycos(\theta) - ty \end{bmatrix}

Only… this rotated the selected group, but also translated away from its origin point. So now I’m trying to figure out what I need to do to get the node to end up in the same spot after rotation. But another thought has just occurred. I don’t actually need to translate it back again, I can just perform the initial translation to the centre, do the rotation, and then I’ll be appending the rotated shape to a different frame anyway, so the position won’t matter. The critical point is that the rotation happens around the central point. It make prove trick when it comes to performing the rotation just on a ‘spin’ layer, but I’ll cross that bridge when I get there.

Rotation problem

Screenshot 2022-09-10 at 22.45.12.png

Currently, when I rotate the knob, the bounding box is forcing the movement away from the previous element and the top of the frame. To solve this, I could try and not use the auto layout, but instead just put them in position manually based on an increment and an offset of their width.

Screenshot 2022-09-10 at 23.01.47.png

I am using a gutter to make sure there is space between each element, and that is being applied correctly, and the xx value seems to be correct in terms of spacing, but the yy value is being skewed. This must still be to do with somehow not rotating around the centre point.

Thanks to Luke Finch’s gist, found the code he used to rotate around the centre.

https://gist.github.com/LukeFinch/d3c93d79a9dcd6970358be1d17838318

let angle = 45
let theta = angle * (Math.PI / 180) //radians

let sel = figma.currentPage.selection[0]
//cx,cy is the center of the node
let cx = sel.x + sel.width / 2
let cy = sel.y + sel.height / 2
let newx =
  Math.cos(theta) * sel.x +
  sel.y * Math.sin(theta) -
  cy * Math.sin(theta) -
  cx * Math.cos(theta) +
  cx
let newy =
  -Math.sin(theta) * sel.x +
  cx * Math.sin(theta) +
  sel.y * Math.cos(theta) -
  cy * Math.cos(theta) +
  cy

sel.relativeTransform = [
  [Math.cos(theta), Math.sin(theta), newx],
  [-Math.sin(theta), Math.cos(theta), newy],
]

Positioning the Knobs

The knobs are now rotating correctly, I just need to also position them equally, and also add them into a group, or let the user do it. The first element should be an exact copy of the selection, then the rotation begins from there. Each element is pushed widthwidth further along. This would mean element one is 0, element two is x=width1x = width * 1, element three is x=width2x = width * 2

Screenshot 2022-09-11 at 00.16.26.png

That’s what I get with that formula. Because the width of the bounds increases as the circle rotates, it means it gets pushed inside the previous knob if left at 100. What makes it trick is that when I access the width property, it is still 100, as the transformation is applied as a property, but the width doesn’t seem to be updated based on that transformation. Got it. Was able to spread the knobs out first, then apply the relative transformation once the new xx coordinate had been set.

The Rotation Amount

The user should be providing the start position of the knob, and I should figure out the end position. The end position should simply be the mirror image of the start, but that might be tricky to figure out. Either way, I think that’s a task for tomorrow.

Added the parameters

Figma allows plugins to run with user-accepted parameters. They can be defined in the manifest like so:

{
  "name": "Knob Rotator",
  "id": "1150213363013997713",
  "api": "1.0.0",
  "main": "code.js",
  "editorType": ["figma"],
  "parameters": [
    {
      "name": "Degrees",
      "key": "degrees",
      "allowFreeform": true
    },
    {
      "name": "Frames",
      "key": "frames",
      "allowFreeform": true
    }
  ]
}

Here I have added to parameteres, degrees and frames. degrees allows the user to define how far they want the knob to rotate. The frames define over how many frames they want that rotation to occur. So if you wanted a full rotation over 30 frames, you'd enter 360 for the degrees and 30 for the frames. I was also able to provide suggested values for the parameters.

This then gives the user an easy way to input information without having to build out my own UI for the plugin (overkill for such a small amount of input).

Figma plugin parameters

Parent hierarchy spacing

Then, I needed to get the alignment set up. For Kontakt, the supplied image must have perfectly equal spacing so the total size split into the number of frames specified gets each individual frae perfectly. To export the generated knobs, the easiest way for the user is to have them grouped, and export the entire group as one image. I set up the groups, only to discover that the necessary alignment options aren't available for groups with the API. So, I needed to group the knobs all into one group, and then put that group into a frame, at which point I can set the alignment of the frame and the internal group to make sure it has the correct vertical and horizontal padding. Then set the frame opacity one 0, and it's ready to generate a neat set of knobs.

Neat line of knobs

Levelling up

The next level to take this to would be for the user-selection to be able to have a rotatable element, but not spin the entire selection - i.e spinning the pointer on the knob, but not the entire knob itself. I also noticed a number of people asking about a slider version of this type of plugin, so perhaps I'll build that one next.