Skip to content

Commit 78d2232

Browse files
committed
Add new Tailwind gradients
1 parent 918977a commit 78d2232

File tree

3 files changed

+225
-55
lines changed

3 files changed

+225
-55
lines changed

packages/backend/src/tailwind/builderImpl/tailwindColor.ts

Lines changed: 215 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -149,20 +149,34 @@ export const tailwindGradientFromFills = (
149149
return tailwindGradient(fill);
150150
}
151151

152-
// Use arbitrary values with HTML-based gradient syntax for other gradient types
153-
if (fill.type === "GRADIENT_ANGULAR") {
154-
return tailwindArbitraryGradient(htmlAngularGradient(fill));
155-
}
152+
// Tailwind 4 has built-in support for radial and conic gradients
153+
if (localTailwindSettings.useTailwind4) {
154+
if (fill.type === "GRADIENT_RADIAL") {
155+
return tailwindRadialGradient(fill);
156+
}
157+
if (fill.type === "GRADIENT_ANGULAR") {
158+
return tailwindConicGradient(fill);
159+
}
160+
// Diamond is still too complex for direct Tailwind support
161+
if (fill.type === "GRADIENT_DIAMOND") {
162+
return "";
163+
}
164+
} else {
165+
// Use arbitrary values with HTML-based gradient syntax for other gradient types
166+
if (fill.type === "GRADIENT_ANGULAR") {
167+
return tailwindArbitraryGradient(htmlAngularGradient(fill));
168+
}
156169

157-
if (fill.type === "GRADIENT_RADIAL") {
158-
return tailwindArbitraryGradient(htmlRadialGradient(fill));
159-
}
170+
if (fill.type === "GRADIENT_RADIAL") {
171+
return tailwindArbitraryGradient(htmlRadialGradient(fill));
172+
}
160173

161-
if (fill.type === "GRADIENT_DIAMOND") {
162-
// Diamond is too complex, it is going to create 3 linear gradients, which gets too weird in Tailwind.
163-
return "";
174+
if (fill.type === "GRADIENT_DIAMOND") {
175+
// Diamond is too complex, it is going to create 3 linear gradients, which gets too weird in Tailwind.
176+
return "";
177+
}
164178
}
165-
179+
166180
return "";
167181
};
168182

@@ -177,59 +191,208 @@ const tailwindArbitraryGradient = (cssGradient: string): string => {
177191
return `bg-[${tailwindValue}]`;
178192
};
179193

180-
export const tailwindGradient = (fill: GradientPaint): string => {
181-
const direction = gradientDirection(gradientAngle(fill));
194+
/**
195+
* Maps an angle to a gradient direction class for both Tailwind 3 and 4
196+
* @param angle The angle in degrees
197+
* @param useTailwind4 Whether to use Tailwind 4 syntax
198+
* @returns The appropriate gradient direction class
199+
*/
200+
const directionMap: Record<number, { tailwind3: string; tailwind4: string }> = {
201+
0: { tailwind3: "bg-gradient-to-r", tailwind4: "bg-linear-to-r" },
202+
45: { tailwind3: "bg-gradient-to-br", tailwind4: "bg-linear-to-br" },
203+
90: { tailwind3: "bg-gradient-to-b", tailwind4: "bg-linear-to-b" },
204+
135: { tailwind3: "bg-gradient-to-bl", tailwind4: "bg-linear-to-bl" },
205+
"-45": { tailwind3: "bg-gradient-to-tr", tailwind4: "bg-linear-to-tr" },
206+
"-90": { tailwind3: "bg-gradient-to-t", tailwind4: "bg-linear-to-t" },
207+
"-135": { tailwind3: "bg-gradient-to-tl", tailwind4: "bg-linear-to-tl" },
208+
180: { tailwind3: "bg-gradient-to-l", tailwind4: "bg-linear-to-l" },
209+
};
210+
211+
function getGradientDirectionClass(angle: number, useTailwind4: boolean): string {
212+
let snappedAngle = nearestValue(angle, [
213+
0, 45, 90, 135, 180, -45, -90, -135, -180,
214+
]);
215+
if (snappedAngle === -180) snappedAngle = 180;
216+
217+
// Check if angle is in the map
218+
const entry = directionMap[snappedAngle];
219+
if (entry) {
220+
return useTailwind4 ? entry.tailwind4 : entry.tailwind3;
221+
}
222+
223+
// For non-standard angles in Tailwind 4, use exact angle
224+
if (useTailwind4) {
225+
const exactAngle = Math.round(((angle % 360) + 360) % 360);
226+
return `bg-linear-${exactAngle}`;
227+
}
228+
229+
// Fallback for Tailwind 3 (nearest standard direction)
230+
return snappedAngle === 180 ? "bg-gradient-to-l" : "bg-gradient-to-r";
231+
}
232+
233+
/**
234+
* Check if a stop position needs a position override
235+
* @param actual The actual position (0-1)
236+
* @param expected The expected default position (0-1)
237+
* @returns True if position needs to be specified
238+
*/
239+
const needsPositionOverride = (actual: number, expected: number): boolean => {
240+
// Only include position if it deviates by more than 5% from expected
241+
return Math.abs(actual - expected) > 0.05;
242+
};
243+
244+
/**
245+
* Gets position modifier string for a gradient stop if needed
246+
* @param stopPosition The stop position (0-1)
247+
* @param expectedPosition The expected default position (0-1)
248+
* @param unit The unit to use (%, deg)
249+
* @param multiplier Multiplier for the position value
250+
* @returns Position string or empty string
251+
*/
252+
const getStopPositionModifier = (
253+
stopPosition: number,
254+
expectedPosition: number,
255+
unit: string = "%",
256+
multiplier: number = 100
257+
): string => {
258+
if (needsPositionOverride(stopPosition, expectedPosition)) {
259+
const position = Math.round(stopPosition * multiplier);
260+
return ` ${position}${unit}`;
261+
}
262+
return "";
263+
};
264+
265+
/**
266+
* Generates a complete gradient stop with position if needed
267+
* @param prefix The stop prefix (from-, via-, to-)
268+
* @param stop The gradient stop
269+
* @param globalOpacity The global opacity
270+
* @param expectedPosition The expected default position (0-1)
271+
* @param unit The unit to use (%, deg)
272+
* @param multiplier Multiplier for the position value
273+
* @returns Complete gradient stop string
274+
*/
275+
function generateGradientStop(
276+
prefix: string,
277+
stop: ColorStop,
278+
globalOpacity: number = 1.0,
279+
expectedPosition: number,
280+
unit: string = "%",
281+
multiplier: number = 100
282+
): string {
283+
const colorValue = tailwindGradientStop(stop, globalOpacity);
284+
const colorPart = `${prefix}-${colorValue}`;
182285

183-
// Get the overall fill opacity
184-
const globalOpacity = fill.opacity !== undefined ? fill.opacity : 1.0;
286+
if (!localTailwindSettings.useTailwind4) {
287+
return colorPart;
288+
}
289+
290+
// Only add position if it significantly differs from the default
291+
const positionModifier = getStopPositionModifier(
292+
stop.position,
293+
expectedPosition,
294+
unit,
295+
multiplier
296+
);
297+
return positionModifier ? `${colorPart} ${prefix}${positionModifier}` : colorPart;
298+
}
299+
300+
export const tailwindGradient = (fill: GradientPaint): string => {
301+
const globalOpacity = fill.opacity ?? 1.0;
302+
const direction = getGradientDirectionClass(
303+
gradientAngle(fill),
304+
localTailwindSettings.useTailwind4
305+
);
185306

186307
if (fill.gradientStops.length === 1) {
187-
const fromColor = tailwindGradientStop(
188-
fill.gradientStops[0],
189-
globalOpacity,
190-
);
191-
return `${direction} from-${fromColor}`;
308+
const fromStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0);
309+
return [direction, fromStop].filter(Boolean).join(" ");
192310
} else if (fill.gradientStops.length === 2) {
193-
const fromColor = tailwindGradientStop(
194-
fill.gradientStops[0],
195-
globalOpacity,
196-
);
197-
const toColor = tailwindGradientStop(fill.gradientStops[1], globalOpacity);
198-
return `${direction} from-${fromColor} to-${toColor}`;
311+
const firstStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0);
312+
const lastStop = generateGradientStop("to", fill.gradientStops[1], globalOpacity, 1);
313+
return [direction, firstStop, lastStop].filter(Boolean).join(" ");
199314
} else {
200-
const fromColor = tailwindGradientStop(
201-
fill.gradientStops[0],
315+
const firstStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0);
316+
const viaStop = generateGradientStop("via", fill.gradientStops[1], globalOpacity, 0.5);
317+
const lastStop = generateGradientStop(
318+
"to",
319+
fill.gradientStops[fill.gradientStops.length - 1],
202320
globalOpacity,
321+
1
203322
);
204-
// middle (second color)
205-
const viaColor = tailwindGradientStop(fill.gradientStops[1], globalOpacity);
206-
// last
207-
const toColor = tailwindGradientStop(
323+
return [direction, firstStop, viaStop, lastStop].filter(Boolean).join(" ");
324+
}
325+
};
326+
327+
/**
328+
* Generate Tailwind 4 radial gradient
329+
*/
330+
const tailwindRadialGradient = (fill: GradientPaint): string => {
331+
const globalOpacity = fill.opacity ?? 1.0;
332+
const [center] = fill.gradientHandlePositions;
333+
const cx = Math.round(center.x * 100);
334+
const cy = Math.round(center.y * 100);
335+
const isCustomPosition = Math.abs(cx - 50) > 5 || Math.abs(cy - 50) > 5;
336+
const baseClass = isCustomPosition ? `bg-radial-[at_${cx}%_${cy}%]` : "bg-radial";
337+
338+
if (fill.gradientStops.length === 1) {
339+
const fromStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0);
340+
return [baseClass, fromStop].filter(Boolean).join(" ");
341+
} else if (fill.gradientStops.length === 2) {
342+
const firstStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0);
343+
const lastStop = generateGradientStop("to", fill.gradientStops[1], globalOpacity, 1);
344+
return [baseClass, firstStop, lastStop].filter(Boolean).join(" ");
345+
} else {
346+
const firstStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0);
347+
const viaStop = generateGradientStop("via", fill.gradientStops[1], globalOpacity, 0.5);
348+
const lastStop = generateGradientStop(
349+
"to",
208350
fill.gradientStops[fill.gradientStops.length - 1],
209351
globalOpacity,
352+
1
210353
);
211-
return `${direction} from-${fromColor} via-${viaColor} to-${toColor}`;
354+
return [baseClass, firstStop, viaStop, lastStop].filter(Boolean).join(" ");
212355
}
213356
};
214357

215-
const gradientDirection = (angle: number): string => {
216-
switch (nearestValue(angle, [-180, -135, -90, -45, 0, 45, 90, 135, 180])) {
217-
case 0:
218-
return "bg-gradient-to-r";
219-
case 45:
220-
return "bg-gradient-to-br";
221-
case 90:
222-
return "bg-gradient-to-b";
223-
case 135:
224-
return "bg-gradient-to-bl";
225-
case -45:
226-
return "bg-gradient-to-tr";
227-
case -90:
228-
return "bg-gradient-to-t";
229-
case -135:
230-
return "bg-gradient-to-tl";
231-
default:
232-
// 180 and -180
233-
return "bg-gradient-to-l";
358+
/**
359+
* Generate Tailwind 4 conic gradient
360+
*/
361+
const tailwindConicGradient = (fill: GradientPaint): string => {
362+
const [center, , startDirection] = fill.gradientHandlePositions;
363+
const globalOpacity = fill.opacity ?? 1.0;
364+
const dx = startDirection.x - center.x;
365+
const dy = startDirection.y - center.y;
366+
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
367+
angle = (angle + 360) % 360;
368+
const normalizedAngle = Math.round(angle);
369+
const cx = Math.round(center.x * 100);
370+
const cy = Math.round(center.y * 100);
371+
const isCustomPosition = Math.abs(cx - 50) > 5 || Math.abs(cy - 50) > 5;
372+
let baseClass = `bg-conic-${normalizedAngle}`;
373+
374+
if (isCustomPosition) {
375+
baseClass = `bg-conic-[from_${normalizedAngle}deg_at_${cx}%_${cy}%]`;
376+
}
377+
378+
if (fill.gradientStops.length === 1) {
379+
const fromStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0, "deg", 360);
380+
return [baseClass, fromStop].filter(Boolean).join(" ");
381+
} else if (fill.gradientStops.length === 2) {
382+
const firstStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0, "deg", 360);
383+
const lastStop = generateGradientStop("to", fill.gradientStops[1], globalOpacity, 1, "deg", 360);
384+
return [baseClass, firstStop, lastStop].filter(Boolean).join(" ");
385+
} else {
386+
const firstStop = generateGradientStop("from", fill.gradientStops[0], globalOpacity, 0, "deg", 360);
387+
const viaStop = generateGradientStop("via", fill.gradientStops[1], globalOpacity, 0.5, "deg", 360);
388+
const lastStop = generateGradientStop(
389+
"to",
390+
fill.gradientStops[fill.gradientStops.length - 1],
391+
globalOpacity,
392+
1,
393+
"deg",
394+
360
395+
);
396+
return [baseClass, firstStop, viaStop, lastStop].filter(Boolean).join(" ");
234397
}
235398
};

packages/plugin-ui/src/codegenPreferenceOptions.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { LocalCodegenPreferenceOptions, SelectPreferenceOptions } from "types";
22

33
export const preferenceOptions: LocalCodegenPreferenceOptions[] = [
4+
{
5+
itemType: "individual_select",
6+
propertyName: "useTailwind4",
7+
label: "Tailwind 4",
8+
description: "Enable Tailwind CSS version 4 features and syntax.",
9+
isDefault: false,
10+
includedLanguages: ["Tailwind"],
11+
},
412
{
513
itemType: "individual_select",
614
propertyName: "showLayerNames",
@@ -32,7 +40,7 @@ export const preferenceOptions: LocalCodegenPreferenceOptions[] = [
3240
label: "Color Variables",
3341
description:
3442
"Export code using Figma variables as colors. Example: 'bg-background' instead of 'bg-white'.",
35-
isDefault: false,
43+
isDefault: true,
3644
includedLanguages: ["HTML", "Tailwind", "Flutter"],
3745
},
3846
{

packages/plugin-ui/src/components/CodePanel.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@ const CodePanel = (props: CodePanelProps) => {
109109
// Define preference grouping based on property names
110110
const essentialPropertyNames = ["jsx"];
111111
const stylingPropertyNames = [
112-
"styledComponents",
113-
"exportCSS",
112+
"useTailwind4",
114113
"roundTailwindValues",
115114
"roundTailwindColors",
116115
"useColorVariables",

0 commit comments

Comments
 (0)