Creating a Figma Plugin for Tailwind 4
A heads up for this rambling blog post. After surgery, the thing I’m struggling with most aside from the physical is remembering what I’ve been doing. Until that’s got better/I’m more comfortable with it, I’ve been keeping little blogs about what I’ve been doing. In this post, it’s a play by play of me making a Figma plugin for Tailwind 4.
It is of little purpose for anyone, but maybe someone will find a use for it. It’s not a tutorial, but at the end you’ll have access to my code.
Lets talk about Figma and Tailwind (skip to not see opinions)
I like Figma, A Lot (tm). It is one of the most impressive web tools out there in terms of usefulness for both solo work and teams, and it’s honestly astounding performance in a browser. I put it up in the pantheon of greats with other services like Photopea, and more recently tldraw.
My Tailwind take is a little more nuanced. It is my favourite of the styling libraries, a solid evolution of those utility classes you used to get on Bootstrap. It can allow a startup to go from nothing to something really rapidly, in the right hands.
The problem for me is to truly make use of it beyond the defaults, and to make more complex websites, you need to know CSS to understand what you’re putting together. And if you know CSS, I think it can be hard to understand why you’re using Tailwind at all (It's not until you go back and try without that you realise). For me at least, the benefits of knowing CSS, and having Tailwinds build tools just work with most libraries I use is enough. But if you’re just starting out, I recommend using just CSS with your projects for a bit. That way when you do move to Tailwind, you’ll be a step ahead of anyone who doesn’t, especially with Tailwind 4.
Tailwind 4
A couple weeks ago the Tailwind 4 alpha got released, and they’re changing up how theming works. If you haven’t used Tailwind 3 yet, it uses the standard JavaScript file module export, like all your other build tools. It’s good, means it comes with some typing. It’s familiar.
Tailwind 4 is moving to instead theming/extending with CSS variables. This is great for a few reasons, but to me the prime one is it means we can natively reference our Tailwind theme colours in any CSS we need to write alongside out Tailwind in our HTML, rather than using theme()
. Also it means your styling library actually touches some CSS, which you can see! There’s a bunch of other improvements, and more stuff in the pipeline (it is not production ready).
But who cares, let’s make a quick Figma plugin.
Why are we making a Figma plugin
There are thousands upon thousands (A whole 177 at time of writing) Tailwind Figma plugins and templates which do a variety of things, and a few which do export to a Tailwind config. They have a variety of pros and cons, but there are two things from the couple I’ve tried:
- For styles names, and folders, they don’t format it in a way which I think is very “Tailwind-y”. I want a more opinionated export, which feels seamless for using in the existing Tailwind defaults.
- They don’t work with Tailwind 4 (yet), so lets make another plugin :^)
That’s it really. Plus learning and trying out a new thing is fun! I’ve never made a Figma plugin before, and I don’t know if I will again. Maybe the Developer mode in Figma already does this as I think it lets you export CSS variables, I don’t know I’m not going to give them money to find out.
Starting a Figma plugin
To start off with, I’m going to give you three links.
First is the example Figma file, which has various examples of a documents Styles to try and export. We’ll go through each of these one by one, and if we have some time lets try and do some document variables too! There’s no design in here, because there doesn’t need to be!
Second is the Figma documentation about starting a plugin. If you only need something super basic (No tests, no front end frameworks etc) just use this.
Third is this video by Mykhaylo Ryechkin, which gives you a far more flexible boilerplate for a Plugin than what Figma gives you. It’s just the generated Vite template with the generated Figma files like manifest.json
put into it, so it works as a plugin. It’s simple, to the point and the video goes over what each file you’re editing/copying over does really nicely. It’s great, thank you!
Right, so we now have our Vite boilerplate ready, what the heck are we doing with it?
Tailwindify our Solid Color Styles
Lets first take a look at our Solid Color Styles in the Figma document:
At work this is the typical setup we can expect for our design system. So considering I want something that I can use at work and for my own personal work, this seems like the most sensible setup.
I spoke before about wanting something opinionated, so here are a few rules I want the exported CSS variable names to follow:
- It should be all lowercase.
- It should be
kebab-case
, with no spaces. - It should only have unique words next to each other (e.g. no
primary-primary-red
).
And I think that matches Tailwind pretty well. Here’s some vitest tests to that effect:
describe('parseColorStyleNameToCssName', () => {
test('converts top level name into lower case and kebab case', () => {
expect(parseColorStyleNameToCssName('Top Level Color')).toBe('top-level-color');
});
test('converts folder into lower case and kebab case', () => {
expect(parseColorStyleNameToCssName('Primary/Darker')).toBe('primary-darker');
});
test('converts folder with duplicated first index for a part into lower case, kebab case', () => {
expect(parseColorStyleNameToCssName('Primary/Primary')).toBe('primary');
});
test('converts folder with duplicated first index for a part and extra info into lower case, kebab case', () => {
expect(parseColorStyleNameToCssName('Primary/Primary Dark')).toBe('primary-dark');
});
test('converts deeply nested folder with duplicated first index for a part and extra info into lower case, kebab case', () => {
expect(parseColorStyleNameToCssName('Primary/Nested/Primary Dark')).toBe('primary-nested-dark');
});
test('converts deeply nested folder with duplicated first index and extra info into lower case, kebab case', () => {
expect(parseColorStyleNameToCssName('Primary/Nested/Primary Primary')).toBe('primary-nested-primary');
});
});
And a quick implementation which resolves these tests (this is all basic stuff I’m just enjoying padding out this post):
/**
* Parse the incoming Figma Color Style name into a nice Tailwind name
*
* @param name Name as returned by Figma
*/
export function parseColorStyleNameToCssName(name: string): string {
// Split the folder structure up
const structure = name.split('/');
if (structure.length === 1) {
return structure[0].replace(/ /ig, '-').toLowerCase();
}
const toReturn: string[] = [];
for(const part of structure) {
// Split by space so we can compare the start of the string
const currentLevel = part.split(' ');
// Remove duplicate definitions
if (toReturn.includes(currentLevel[0].toLowerCase())) {
currentLevel.shift();
}
toReturn.push(currentLevel.join('-').toLowerCase());
}
return toReturn.filter(str => str.length > 0).join('-');
}
Boom, all our colour names are looking correct. Lets put them together into a copyable list of CSS variables, with their colours. We can make it configurable later, but for now we’re going to send the colours as rgba
, including their opacity. I thought about having the opacity
separate from the rest of the colour like having --color-primary
and --color-primary-opacity
. But I don’t think that translates the designs very well, and I don’t know it’s what you should be doing in either your Figma or frontend. You should use a colour as it is defined, including opacity. Otherwise you’ve defined two colours, which will absolutely be mis-used!
Tangent about refactoring and code golf (Skip to get back to Figma)
I don’t do TDD anymore. I often don’t need to focusing on the frontend where at least in my career tests have had a tenuous place on our stacks anyway. We often push for more testing on the UI, but when you have a great QA team, and join a company with a focus on pushing features, testing is absolutely the first thing that gets dropped.
But this was a rare situation where I wrote some tests with expected outputs, wrote the code which fulfilled it, and could then refactor it.
The code above is simple, it’s not doing a whole lot of anything. But it can definitely be slimmed down a bit, so let’s take it step by step. First up, it turned out we didn’t really need that first if
:
export function parseColorStyleNameToCssName(name: string): string {
const toReturn: string[] = [];
for(const part of name.split('/')) {
// As above
}
return toReturn.filter(str => str.length > 0).join('-');
}
We can make use of some of the built in array methods like reduce
and filter
to make it a bit more modern “JavaScript”:
export function parseColorStyleNameToCssName(name: string): string {
return name.split('/').reduce((total: string[], part) => {
// Split by space so we can compare the start of the string
const currentLevel = part.split(' ');
// Remove duplicate definitions
if (total.includes(currentLevel[0].toLowerCase())) {
currentLevel.shift();
}
return [...total, currentLevel.join('-').toLowerCase()];
}, []).filter(str => str.length > 0).join('-');
}
Finally, lets make it as small as possible:
export function parseColorStyleNameToCssName(name: string): string {
return name.toLowerCase().split('/').reduce((total: string[], part) =>
[...total, part.split(' ').filter((val, index) => index > 0 || (index === 0 && !total.includes(val))).join('-')]
, []).filter(Boolean).join('-');
}
Neat! I wonder how much this has improved the performance of the original code. Because we reduced the number of lines and readability so much to get here, it must be a big impact!
Original
PerformanceMeasure {
name: 'parseColorStyleNameToCssName',
entryType: 'measure',
startTime: 19.933500051498413,
duration: 0.36758291721343994
}
Final
PerformanceMeasure {
name: 'parseColorStyleNameToCssName',
entryType: 'measure',
startTime: 20.033125042915344,
duration: 0.4230839014053345
}
Oh, it’s actually slower! Turns out that the array methods are basically the same speed, but with a bit more overhead so they reduce performance (probably, I don’t know).
Even if the original was slower, it was so much nicer than the final code because it was readable. It just took a quick glance over it to understand what was going on.
God please don’t use reduce
.
Sending messages from the plugin back to the UI
So we’ve got our colours chucked together into a big old string, ready to be copy and pasted. I don’t know if you can download a file from the Figma plugin onto your system, I don’t really care! It makes more sense to me to copy and paste it, as you’ll probably want to merge it with an existing CSS file.
We send messages back to the ui
the same way we send messages to the plugin. Which means:
// exportToCss is just putting together all our variables into a single string
figma.ui.postMessage({ type: 'css', css: exportToCss(exportRows) })
And then on the ui
having an event listener waiting for the message:
useEffect(() => {
window.addEventListener("message", handleIncomingMessage);
// I spent like an hour wondering why this didn't work, and it's because below I was just returning the removeEventListener rather than a function.
// it's nice to be doing something for so long, and still do such silly mistakes!
return () => window.removeEventListener("message", handleIncomingMessage);
}, []);
We want to route the incoming message in basically the exact same way as the plugin side, so the handleIncomingMessage
will just have a bunch of if
statements. We’re not here to make a massive, extendable plugin. Just a proof of concept for Tailwind 4, remember! So a few basic if’s will do. Because we’ve kept the abstraction simple, if we ever extend it should be easy.
const handleIncomingMessage = (event: MessageEvent<IncomingMessages>) => {
if (event.data.pluginMessage.type === 'css') {
setCss(event.data.pluginMessage.css);
}
}
We added this new bit of state in const [css, setCss]
which holds the returned CSS for our colour styles. We can display this inside <pre><code>
tags. And voila!
It’s nothing much, but it’s functional. Let’s copy and paste it into a basic Tailwind 4 project, and see if it works.
Awesome! Now let’s look at some of the other style values, and we’ll worry about gradients later.
Text styles
Our Figma text styles have four values which we care about for our POC: text size, font family, font weight and line height.
In our opinionated approach, we’re going to reduce the chance for duplication here on the font family front. Each font can only appear once in our exported CSS variables, and it’ll have the name of the font family in it. The other three values will be both included, and numerical values formatted to rem
.
It’s not necessary, but if we want to format to rem
then we should at least make the root font size we’re going to be working with configurable (even if it will basically always be 16px
).
// In our App.tsx
const [baseFontSize, setBaseFontSize] = useState(16);
// Somewhere in the DOM
<label htmlFor="base_font_size">
Base font size
</label>
<input
id="base_font_size"
value={baseFontSize}
onChange={(e) => setBaseFontSize(parseInt(e.target.value, 10))}
type="number"
/>
And we’ll pass that along to the plugin as baseFontSize
in the message.
On the receiving end, we should now have all the information we need to start compiling out text styles. Lets get it.
const textStyles = await figma.getLocalTextStylesAsync();
const fontFamilies: string[] = [];
for (const textStyle of textStyles) {
// Add new font families to the array
if (!fontFamilies.includes(textStyle.fontName.family)) {
fontFamilies.push(textStyle.fontName.family);
}
exportRows.push({
name: parseFigmaNameToCssName(textStyle.name),
type: 'font-size',
value: convertPixelsToRem(textStyle.fontSize, msg.baseFontSize)
});
exportRows.push({
name: parseFigmaNameToCssName(textStyle.name),
type: 'font-weight',
value: parseFontStyleToNumber(textStyle.fontName.style as AvailableFontTypes)
});
// To type .value
if (textStyle.lineHeight.unit === 'PIXELS') {
exportRows.push({
name: parseFigmaNameToCssName(textStyle.name),
type: 'line-height',
value: convertPixelsToRem(textStyle.lineHeight.value, msg.baseFontSize)
});
}
}
fontFamilies.forEach((fontFamily) => {
exportRows.push({
name: fontFamily.toLowerCase().replace(/ /gi, '-'),
type: 'font-family',
value: fontFamily
});
});
This is wordy for sure, but pretty clear. We’re going through each part of the textStyle
we care about for this project, and adding another CSS variable for each field. convertPixelsToRem
just does fontSize / base
+ rem
, and parseFontStyleToNumber
while poorly named just changes up the font style to it’s expected numerical value. I also realised that the function to parse the name isn’t useful for /just/ the colours, but all of the different styles. So it got renamed to parseFigmaNameToCssName
. Progressive development.
Our exportToCss
function which changes each entry into a css variable needs to get a wee bit more complicated, with the addition of wrapping font-family
in double quotes. It’s a bit of a messy one liner, but it’s at an acceptable level of complexity to me. If it needs any more additions, then we should look at making this more modular/splitting over multiple lines.
export function exportToCss(values: ExportValue[]): string {
return `@theme {
${values.map(({ name, value, type }) => `--${type}-${name}: ${type === 'font-family' ? `"${value}"` : value}'\n\t')}
}`
}
Lets export once again and bring it into our Tailwind 4 test application, and try it out. Lets compare how it looks in Figma and how it looks on the page:
Sick nasty. We’re obviously missing loads here of what the designers can configure for text styles, but it’s a good start, and let’s give a quick look at the next style.
Effect Styles
There are 4 different types of effect style one can use: Inner Shadow, Drop Shadow, Layer Blur, and Background Blur. We’re only going to quickly look at drop shadow. This will be a box-shadow
in CSS.
A first port of call is MDN’s page on box-shadow
. So there’s a few overloads for the box-shadow
, so what can Figma give to us, so we can decide which one we should go for?
Alright, it’s looking like this is the one to go for! It’ll cover all of the expected values Figma can give us.
/* Four length values and a color */
/* <length> | <length> | <length> | <length> | <color> */
box-shadow: 2px 2px 2px 1px rgb(0 0 0 / 20%);
Here the first two values x
and y
, third is radius and fourth spread. Figma gives us all of these in this handy object from the effects
array inside the style object.
// You get an array of these under effectStyle.effects
{
blendMode: "NORMAL",
boundVariables: {},
color: {r: 0, g: 0, b: 0, a: 0.25},
offset: {x: 0, y: 4},
radius: 4,
showShadowBehindNode: false,
spread: 0,
type: "DROP_SHADOW",
visible: true
}
With that, a quick builder function, and we should basically be done! Lets give it a go on the export and see what we get back:
Oh, huh. Alright we should probably remove all non alphanumeric characters from the names. Lets write a quick test for that, and add it to our name parser:
// Test
test('removes non alphanumeric characters from the string', () => {
expect(parseFigmaNameToCssName('Primary/Nested/Accent (Dark)')).toBe('primary-nested-accent-dark');
});
// Updated parser, this is the only line we changed. map is probably the nicest way of doing this
toReturn.push(currentLevel.map((c) => c.replace(/[\W_]+/g,"")).join('-').toLowerCase());
// And it passes!
And here, now, finally. How does it look:
--shadow-shadow-4-dark: 0rem 0.25rem 0.25rem 0rem rgba(0, 0, 0, 0.25);
--shadow-shadow-3-medium: 0rem 0.1875rem 0.1875rem 0rem rgba(0, 0, 0, 0.20000000298023224)
Ah fuck, these numbers are cringe! We should probably have a max number of decimal places because I am pretty sure my MacBook screen won’t resolve 0.00000002 of a pixel properly. Putting Number(num.toFixed(4))
in a few places fixes this.
--shadow-shadow-4-dark: 0rem 0.25rem 0.25rem 0rem rgba(0, 0, 0, 0.25);
--shadow-shadow-3-medium: 0rem 0.1875rem 0.1875rem 0rem rgba(0, 0, 0, 0.2)
Second is the duplicate names! This one is a bit annoying to implement, but lets give it a go with an optional type
parameter on the parse function, which the current part of the name isn’t a match for.
--shadow-4-dark: 0rem 0.25rem 0.25rem 0rem rgba(0, 0, 0, 0.25);
--shadow-3-medium: 0rem 0.1875rem 0.1875rem 0rem rgba(0, 0, 0, 0.2)
Looks good to me! And how it looks in Figma, and on our quick test Tailwind 4 site:
Dog fooding it
We’re writing a plugin for Figma, which exports to Tailwind 4, and THIS is what we’re serving you?!?!
Lets install Tailwind 4 into our Plugin, get it setup, make a quick design in Figma, export and implement it into our plugin. Here’s a quick design done in like 15 minutes, it looks crap but serves as a good base for future change.
@theme {
--color-background: rgba(34, 34, 34, 1);
--color-text: rgba(255, 255, 255, 1);
--color-secondary: rgba(255, 255, 255, 1);
--color-primary: rgba(86, 194, 228, 1);
--color-primary-hover: rgba(86, 194, 228, 0.2);
--color-code-background: rgba(75, 75, 75, 1);
--font-size-title: 2rem;
--font-weight-title: 700;
--font-size-details: 1rem;
--font-weight-details: 400;
--font-size-label: 0.875rem;
--font-weight-label: 400;
--font-size-input-value: 1rem;
--font-weight-input-value: 400;
--font-size-code: 0.75rem;
--font-weight-code: 400;
--font-family-atkinson-hyperlegible: "Atkinson Hyperlegible"
}
A bit of React and moving stuff around, here’s what we got.
Conclusion & What’s next
God knows, I did this out of interest and as a bit of a future project for work. This is the first time making a Figma plugin, and the documentation is good but a bit hard to search and figure out what you need to be doing. It's nice that Figma at least lets you make plugins, it's one of the best things about the platform. Their boilerplate really needs to be expanded with some more modern tooling though. The Vite boilerplate with the Figma files ported over works out the box, it's much nicer.
There are loads of things still to integrate into the plugin:
- Gradient colour styles, and all that comes with that (like radial, linear etc).
- The other effects (inner shadow, blur).
- It could do with being more robust around the CSS variable names. In the export above, you use
text-text
for the default text colour. I think I would prefertext-default
or something similar. - Be more configurable! Choose what styles you want exported, how you want them exported (do you want hex colours or rgb, hsl etc).
- QoL improvements: a copy button for the CSS variables so you don’t have to manually select it.
I also submitted it to the Figma plugin marketplace as a free thing. I also wanted to spend next to no time on making the marketing stuff right now, so I made these just, AWFUL, assets to try and show it off. Minimum effort here, it was a weekend project after all.
Links
GitHub repo - https://github.com/EdwardJFox/tailwind_theme_figma_plugin
Figma design file for the project - https://www.figma.com/file/GUsP1GZzqLuOs6AQpGiwid/Tailwind-4-Export-Plugin?type=design&node-id=0%3A1&mode=design&t=KX59p5A7n1FtHqd4-1