Output Type
321 | 327 |Color AMixColor B
331 | 332 | 333 | 334 |Color Blend
338 | 353 | 359 |Color Stops
363 | 364 |Variable Collection
368 | 369 |├── LICENSE ├── code.js ├── index.html └── manifest.json /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jake 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /code.js: -------------------------------------------------------------------------------- 1 | console.clear(); 2 | 3 | const CLIENT_STORAGE_KEY = "css-color-mix"; 4 | const SHAPE_HEIGHT = 50; 5 | const SHAPE_WIDTH = 500; 6 | const WINDOW_HEIGHT = 300; 7 | const WINDOW_WIDTH_SMALL = 240; 8 | const WINDOW_WIDTH_LARGE = 480; 9 | 10 | initialize(); 11 | 12 | async function initialize() { 13 | const settings = await clientStorageRetrieveSettings(); 14 | figma.showUI(__html__, { 15 | height: WINDOW_HEIGHT, 16 | width: 17 | settings && settings.preview === false 18 | ? WINDOW_WIDTH_SMALL 19 | : WINDOW_WIDTH_LARGE, 20 | themeColors: true, 21 | }); 22 | 23 | figma.ui.onmessage = async (message) => { 24 | if (message.type === "RESIZE") { 25 | figma.ui.resize(message.width, message.height); 26 | } else if (message.type === "SETTINGS") { 27 | clientStorageSaveSettings(message.settings); 28 | } else if (message.type === "GRADIENT" || message.type === "FILL") { 29 | actionFillShapeWithColorOrGradient(message); 30 | } else if (message.type === "SWATCHES") { 31 | actionCreateSwatches(message); 32 | } else if (message.type === "VARIABLES") { 33 | actionCreateVariables(message); 34 | } else { 35 | console.log(message); 36 | } 37 | }; 38 | 39 | figma.on("selectionchange", async () => { 40 | const fills = await getFillsFromCurrentSelection(); 41 | if (fills.length) { 42 | figma.ui.postMessage({ type: "FILLS", fills }); 43 | } 44 | }); 45 | 46 | let collections = await getLocalVariableCollections(); 47 | setInterval(async () => { 48 | const latestCollections = await getLocalVariableCollections(); 49 | if (variableCollectionsHaveChanged(collections, latestCollections)) { 50 | collections = latestCollections; 51 | figma.ui.postMessage({ type: "COLLECTIONS", collections }); 52 | } 53 | }, 1000); 54 | 55 | const fills = await getFillsFromCurrentSelection(); 56 | figma.ui.postMessage({ type: "INITIALIZE", collections, fills, settings }); 57 | } 58 | 59 | async function actionCreateSwatches({ payload }) { 60 | const { space, colorA, colorB, colors } = payload; 61 | const frame = figma.createFrame(); 62 | frame.layoutMode = "HORIZONTAL"; 63 | frame.resize(SHAPE_WIDTH, SHAPE_HEIGHT); 64 | frame.fills = []; 65 | frame.name = `color-mix(in ${space}, ${colorA}, ${colorB})`; 66 | 67 | const width = (1 / colors.length) * frame.width; 68 | colors.forEach(({ rgb, colorA, colorB, space }, i) => { 69 | const rect = figma.createRectangle(); 70 | rect.resize(width, frame.height); 71 | rect.layoutGrow = 1; 72 | rect.name = `color-mix(in ${space}, ${colorA}, ${colorB} ${ 73 | Math.round((i / (colors.length - 1)) * 100 * 100) / 100 74 | }%)`; 75 | rect.fills = [figmaSolidFromColor(rgb)]; 76 | frame.appendChild(rect); 77 | }); 78 | selectAndFocusViewportOnNode(frame); 79 | figma.notify("Generated swatches!"); 80 | } 81 | 82 | async function actionCreateVariables({ payload, collection }) { 83 | const { colors } = payload; 84 | const variableCollection = 85 | collection === "__CREATE_NEW_COLLECTION__" 86 | ? figma.variables.createVariableCollection("CSS color-mix()") 87 | : await figma.variables.getVariableCollectionByIdAsync(collection); 88 | 89 | if (!variableCollection) { 90 | figma.notify(`No collection with id "${collection}"`, { 91 | error: true, 92 | }); 93 | return; 94 | } 95 | 96 | try { 97 | colors.forEach(({ rgb, colorA, colorB, space }, i) => { 98 | const ratio = (i / (colors.length - 1)) * 100; 99 | const variable = figma.variables.createVariable( 100 | `${colorA.replace("#", "")}-${colorB.replace( 101 | "#", 102 | "" 103 | )}/in ${space}/${Math.round(ratio * 10)}`, 104 | variableCollection, 105 | "COLOR" 106 | ); 107 | variable.description = relevantCSSForType( 108 | "FILL", 109 | space, 110 | colorA, 111 | colorB, 112 | ratio 113 | ); 114 | variable.setVariableCodeSyntax("WEB", variable.description); 115 | variable.setValueForMode( 116 | variableCollection.defaultModeId, 117 | figmaRGBFromRGB(rgb) 118 | ); 119 | }); 120 | figma.notify(`Created ${colors.length} variables!`); 121 | } catch (e) { 122 | figma.notify(`Error: ${e.message}`, { error: true }); 123 | } 124 | } 125 | 126 | async function actionFillShapeWithColorOrGradient({ type, payload }) { 127 | const { space, colorA, colorB, ratio, colors, color } = payload; 128 | const shapeFromSelection = await getShapeForFillsFromSelection(); 129 | const shape = shapeFromSelection || figma.createFrame(); 130 | const newShape = !shapeFromSelection; 131 | if (newShape) { 132 | shape.resize(SHAPE_WIDTH, SHAPE_HEIGHT); 133 | } 134 | 135 | if (newShape || nodeCanBeRenamedSafely(shape)) { 136 | shape.name = relevantCSSForType(type, space, colorA, colorB, ratio); 137 | } 138 | 139 | shape.fills = [ 140 | type === "GRADIENT" 141 | ? figmaGradientFromColors(colors) 142 | : figmaSolidFromColor(color), 143 | ]; 144 | figma.notify(`Filled with ${type.toLowerCase()}!`); 145 | if (newShape) { 146 | selectAndFocusViewportOnNode(shape); 147 | } 148 | } 149 | 150 | async function clientStorageRetrieveSettings() { 151 | return await figma.clientStorage.getAsync(CLIENT_STORAGE_KEY); 152 | } 153 | 154 | async function clientStorageSaveSettings(args) { 155 | return await figma.clientStorage.setAsync(CLIENT_STORAGE_KEY, args); 156 | } 157 | 158 | function figmaGradientFromColors(colors) { 159 | return { 160 | type: "GRADIENT_LINEAR", 161 | gradientTransform: [ 162 | [1, 0, 0], 163 | [0, 1, 0], 164 | ], 165 | gradientStops: colors.map(({ rgb }, i) => ({ 166 | position: i / (colors.length - 1), 167 | color: Object.assign(figmaRGBFromRGB(rgb), { a: 1 }), 168 | })), 169 | }; 170 | } 171 | 172 | function figmaRGBFromRGB({ r, g, b }) { 173 | return { 174 | r: r / 255, 175 | g: g / 255, 176 | b: b / 255, 177 | }; 178 | } 179 | 180 | function figmaSolidFromColor(color) { 181 | return { 182 | type: "SOLID", 183 | color: figmaRGBFromRGB(color), 184 | opacity: 1, 185 | }; 186 | } 187 | 188 | async function getFillsFromCurrentSelection() { 189 | const fills = []; 190 | if (figma.currentPage.selection.length === 2) { 191 | const fillSolidA = figma.currentPage.selection[0].fills.find( 192 | (fill) => fill.visible && fill.type === "SOLID" 193 | ); 194 | const fillSolidB = figma.currentPage.selection[1].fills.find( 195 | (fill) => fill.visible && fill.type === "SOLID" 196 | ); 197 | if (fillSolidA && fillSolidB) { 198 | fills.push(fillSolidA.color, fillSolidB.color); 199 | } 200 | } 201 | const node = figma.currentPage.selection[0]; 202 | if (!fills.length && node) { 203 | const fillSolid = node.fills.find( 204 | (fill) => fill.visible && fill.type === "SOLID" 205 | ); 206 | const fillGradient = node.fills.find( 207 | (fill) => fill.visible && fill.type.startsWith("GRADIENT") 208 | ); 209 | if (fillGradient) { 210 | const stop1 = fillGradient.gradientStops[0]; 211 | const stop2 = 212 | fillGradient.gradientStops[fillGradient.gradientStops.length - 1]; 213 | if (stop1 && stop2) { 214 | fills.push(stop1.color, stop2.color); 215 | } 216 | } 217 | if (!fills.length && fillSolid) { 218 | fills.push(fillSolid.color); 219 | } 220 | } 221 | return fills; 222 | } 223 | 224 | async function getShapeForFillsFromSelection() { 225 | const selection = figma.currentPage.selection; 226 | if (selection.length !== 1) { 227 | return; 228 | } 229 | const node = selection[0]; 230 | if (!("fills" in node)) { 231 | return; 232 | } 233 | if (!("children" in node)) { 234 | return; 235 | } 236 | if (node.children.length !== 0) { 237 | return; 238 | } 239 | return node; 240 | } 241 | 242 | async function getLocalVariableCollections() { 243 | return (await figma.variables.getLocalVariableCollectionsAsync()).flatMap( 244 | (collection) => { 245 | if (collection.remote) { 246 | return []; 247 | } 248 | return [ 249 | { 250 | id: collection.id, 251 | name: collection.name, 252 | key: collection.id + "-" + collection.name, 253 | }, 254 | ]; 255 | } 256 | ); 257 | } 258 | 259 | function nodeCanBeRenamedSafely(node) { 260 | if (node.type !== "FRAME") { 261 | return false; 262 | } 263 | return ( 264 | node.name.startsWith("linear-gradient(") || 265 | node.name.startsWith("color-mix(") 266 | ); 267 | } 268 | 269 | function relevantCSSForType(type, space, colorA, colorB, ratio) { 270 | if (type === "GRADIENT") { 271 | return `linear-gradient(90deg in ${space}, ${colorA}, ${colorB})`; 272 | } 273 | ratio = ratio === undefined ? "" : ` ${ratio}%`; 274 | return `color-mix(in ${space}, ${colorA}, ${colorB}${ratio})`; 275 | } 276 | 277 | function selectAndFocusViewportOnNode(node) { 278 | figma.currentPage.selection = [node]; 279 | figma.viewport.scrollAndZoomIntoView(figma.currentPage.selection); 280 | figma.viewport.zoom *= 0.6; 281 | } 282 | 283 | function variableCollectionsHaveChanged(collections1, collections2) { 284 | return ( 285 | collections1.map(({ key }) => key).join("-") !== 286 | collections2.map(({ key }) => key).join("-") 287 | ); 288 | } 289 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |Output Type
321 | 327 |Color AMixColor B
331 | 332 | 333 | 334 |Color Blend
338 | 353 | 359 |Color Stops
363 | 364 |Variable Collection
368 | 369 |