§ NOTAS TÉCNICAS aGo · 2026-05-24
Mesh gradients reales con conic-gradient
aGo CSS Gradient genera mesh gradients estilo Stripe, Linear o Apple sin imagen ni canvas, en CSS puro. Esta nota cuenta cómo lo logramos componiendo radial y conic gradients, y dónde el truco no alcanza para imitar un mesh "real" de Figma.
El contexto
Mesh gradients están de moda otra vez. Stripe los usa en hero, Linear los usa en marketing, Apple los usa para anclar mood de sus landings. Visualmente son manchas suaves de color que se mezclan sin bordes duros. Diseñador feliz, developer en problemas.
El developer típico recibe un PNG exportado de Figma con el mesh ya renderizado. El PNG pesa entre 200 KB y 1 MB, no escala (un hero a 4K se ve borroso), no es animable, no se adapta a tema claro/oscuro, y consume un request HTTP que bloquea LCP. Para una landing rápida es trade-off terrible.
aGo CSS Gradient resuelve el problema en la otra dirección: produce un CSS gradient que se acerca visualmente al mesh, escala infinito sin perder nitidez, es animable con keyframes, y pesa cero bytes adicionales sobre el HTML. El usuario lo edita en una UI con manchas arrastrables, copia el CSS resultante y lo pega en su sitio.
El constraint duro es estético: el resultado tiene que verse bien. Si el CSS gradient se ve menos pulido que el PNG de Figma, nadie lo va a usar. Por eso esta nota existe: encontrar la composición que produce un resultado visualmente competitivo.
Las opciones que evaluamos
1. SVG con filter y feGaussianBlur sobre círculos de color
Es la solución más directa. Pintas círculos de colores en un SVG, le
aplicas un feGaussianBlur con stdDeviation alto, y obtienes
manchas suaves que se mezclan. Stripe usa una variante de esto en su
landing.
Ventajas: control granular, blend modes potentes, animable. Contras: el SVG con filtros pesa más que un CSS string, requiere markup HTML específico (no es un fondo "drop-in"), y los filtros gausianos en SVG sobre superficies grandes son más caros en GPU de lo que parece.
2. Canvas 2D rasterizado a una sola vez
Renderizar el mesh en canvas, exportar a PNG inline (base64) y usarlo como background-image. Producible, pero perdemos todas las ventajas que buscábamos: deja de ser escalable, deja de ser editable post-export, deja de ser animable.
3. Composición de radial-gradient + conic-gradient en CSS
La opción menos obvia. Apilas 3-5 capas de gradient con
background y mix-blend-mode. Cada capa es una
mancha de color con falloff radial o cónico. La superposición con
blending produce mezcla suave que se acerca al mesh.
Ventajas: CSS puro (cero bytes), escalable, animable con keyframes sobre background-position, copiar/pegar drop-in. Contras: cada navegador compone blend modes con leves diferencias (Safari rinde un poco distinto a Chrome en darken/multiply), y no es mesh real con control points.
La decisión y por qué
Elegimos la composición CSS de radial y conic. Razones, en orden.
Primera: portabilidad del output. El usuario de aGo CSS Gradient quiere
un string que pegue en su sitio y funcione. Un SVG con filtros requiere
markup. Una imagen requiere asset hosting. Un CSS gradient se pega como
valor de background y ya. Para un 70 % de los casos donde
el mesh es decorativo, esto es suficiente.
Segunda: peso. La salida típica de Gradient pesa entre 200 y 600 bytes. Un PNG mesh exportado de Figma pesa 300 KB. La diferencia en LCP es notable, sobre todo en móvil con red 4G inestable.
Tercera: animación. background-position y
background-size son propiedades baratas de animar en CSS.
Un mesh CSS se puede hacer respirar suavemente con un keyframe de
20 segundos sin costo perceptible en CPU. Un PNG no puede.
Cuarta: tema oscuro. El gradient CSS puede usar variables de tema
(var(--color-mesh-1)) y cambiar de paleta automáticamente
cuando el usuario cambia de modo claro a oscuro. Un PNG requiere dos
assets y switching JS.
Cómo lo implementamos
El núcleo de la generación es un mapper: dado un array de "stops" con posición, color, tamaño y opacidad, producir un string CSS multi-layer. Esto vive en el editor de Gradient y también se expone como utilidad standalone para quien copie el código.
// src/islands/css-gradient/lib/mesh.ts
// Genera un mesh gradient CSS componiendo radial y conic gradients.
// El resultado es un valor de 'background' valido directamente en CSS.
export interface MeshStop {
/** Centro en porcentaje, ej. 20 = 20% desde la izquierda */
x: number;
/** Centro en porcentaje */
y: number;
/** Color en cualquier sintaxis CSS valida (hex, rgb, hsl, oklch) */
color: string;
/** Tamano de la mancha en porcentaje del lado largo. 40 = mancha radio ~40% */
size: number;
/** 0..1, alpha del centro de la mancha */
opacity?: number;
/** Si es 'conic', se renderiza con conic-gradient para crear remolino */
shape?: 'radial' | 'conic';
/** Solo para conic: angulo inicial en grados */
conicFrom?: number;
}
export interface MeshOptions {
/** Color base bajo todas las manchas */
base: string;
/** Lista de manchas, ordenadas de fondo a frente */
stops: MeshStop[];
/** Blend mode usado para apilar las capas */
blendMode?: 'normal' | 'screen' | 'overlay' | 'soft-light' | 'multiply';
}
export function buildMeshBackground(opts: MeshOptions): string {
const blend = opts.blendMode ?? 'screen';
const layers: string[] = [];
for (const stop of opts.stops) {
const alpha = stop.opacity ?? 0.85;
const center = withAlpha(stop.color, alpha);
const fade = withAlpha(stop.color, 0);
if (stop.shape === 'conic') {
const from = stop.conicFrom ?? 0;
layers.push(
`conic-gradient(from ${from}deg at ${stop.x}% ${stop.y}%, ${center}, ${fade} ${stop.size}%, ${center} ${stop.size * 1.6}%, ${fade} ${stop.size * 2}%)`
);
} else {
layers.push(
`radial-gradient(circle at ${stop.x}% ${stop.y}%, ${center} 0%, ${fade} ${stop.size}%)`
);
}
}
// La declaracion final apila las capas con su blend mode.
// 'background' acepta una lista separada por comas; cada capa es independiente.
// El color base va al final como capa solida.
const stack = layers.join(',\n ');
return [
'background:',
` ${stack},\n ${opts.base}`,
`;background-blend-mode: ${repeatBlend(blend, opts.stops.length)};`
].join('\n');
}
function repeatBlend(mode: string, count: number): string {
// La ultima capa (color base) siempre normal para no perder color de fondo.
return [...Array(count).fill(mode), 'normal'].join(', ');
}
function withAlpha(color: string, alpha: number): string {
// Soporta hex (#rrggbb), rgb(), hsl(), oklch().
// Convertimos a sintaxis que soporte alpha cuando sea necesario.
const a = clamp01(alpha);
if (color.startsWith('#')) {
const { r, g, b } = parseHex(color);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
if (color.startsWith('oklch(')) {
// oklch acepta alpha despues de slash: oklch(70% 0.15 25 / 0.5)
return color.replace(/\)$/, ` / ${a})`);
}
if (color.startsWith('hsl(')) {
return color.replace('hsl(', 'hsla(').replace(/\)$/, `, ${a})`);
}
if (color.startsWith('rgb(')) {
return color.replace('rgb(', 'rgba(').replace(/\)$/, `, ${a})`);
}
return color;
}
function parseHex(hex: string): { r: number; g: number; b: number } {
const h = hex.slice(1);
const full = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
return {
r: parseInt(full.slice(0, 2), 16),
g: parseInt(full.slice(2, 4), 16),
b: parseInt(full.slice(4, 6), 16)
};
}
function clamp01(n: number): number {
return Math.max(0, Math.min(1, n));
}
// Ejemplo de uso productivo para un hero estilo Stripe.
export function presetStripeHero(): MeshOptions {
return {
base: '#0B1F33',
blendMode: 'screen',
stops: [
{ x: 18, y: 30, color: '#FF7A3C', size: 55, opacity: 0.85, shape: 'radial' },
{ x: 80, y: 25, color: '#7C3AED', size: 50, opacity: 0.8, shape: 'radial' },
{ x: 50, y: 80, color: '#0EA5E9', size: 65, opacity: 0.75, shape: 'radial' },
{ x: 90, y: 90, color: '#F4EFE6', size: 35, opacity: 0.4, shape: 'conic', conicFrom: 45 }
]
};
}
Tres piezas clave merecen comentario. Primera, la sintaxis del fade: para cada mancha generamos un gradient de "color con alpha alto en el centro, mismo color con alpha 0 en el borde". Eso produce el falloff suave que define al mesh. Sin alpha en el extremo, las manchas tienen borde duro y arruinan el efecto.
Segunda, background-blend-mode: screen en colores oscuros
sobre fondo oscuro produce el efecto "luz" típico de Stripe. Si el
fondo es claro, multiply o soft-light dan
mejor resultado. Lo expusimos como switch en la UI.
Tercera, la capa conic con doble pulso (centro,
transparente, centro, transparente) crea un patrón en remolino que
rompe la perfección radial y agrega textura. Es el truco que separa
un mesh CSS aceptable de uno que parece de stock.
Limitaciones que descubrimos
Cada navegador rinde blend modes con pequeñas diferencias. Chrome y
Firefox producen mezcla casi idéntica en screen y
multiply. Safari (incluso WebKit 17) da resultados un
poco más opacos en overlay y soft-light. Si
tu mesh depende fuertemente de overlay, vas a ver que en Safari se ve
un 10 % distinto. Lo asumimos. Para corrección crítica habría que
meter media queries por user-agent, cosa que evitamos.
No es mesh real. Un mesh gradient real, como el de SVG 2 mesh element o el de Figma, tiene control points en una grilla y produce interpolación entre vértices. Lo nuestro es composición de manchas, que aproxima visualmente pero no permite el control fino de "este punto va a este color exacto". Si necesitas eso, ningún CSS puro lo resuelve hoy.
Las capas con conic-gradient son más caras en GPU que las radiales. En un hero a pantalla completa con cuatro capas de conic, en un Android medio, la animación de background-position cae a 30-40 fps. Limitamos a una capa conic por mesh en los presets que ofrecemos.
Cuando el usuario tiene "reduce motion" habilitado, las animaciones
de mesh se vuelven distractoras o nauseantes. Generamos el CSS con
una media query @media (prefers-reduced-motion: reduce)
que detiene la animación. Lo incluimos por defecto en el output.
Cuándo NO uses este approach
Si el diseño exige reproducir píxel a píxel un mesh que viene de Figma o Sketch, no lo intentes con CSS. Exportá el PNG, optimízalo agresivamente con AVIF o WebP, y úsalo como background-image. Aceptás el costo de un asset, ganás precisión.
Si necesitás control points editables en runtime por el usuario final, el approach CSS no lo permite. Para esa interacción se usa canvas o WebGL con un shader que pinte un mesh real. Ahí el peso del runtime se justifica porque el output es el editor mismo.
Si tu marca tiene un gradient específico que es activo de identidad (no decorativo de fondo), conviene tratarlo como activo de marca y exportar imagen para garantizar consistencia entre navegadores. Aproximar puede traicionar la marca.
Esta nota describe parte de cómo construimos aGo CSS Gradient. Abre Gradient y arrastra manchas para ver el approach en vivo.
¿Necesitas algo parecido para tu producto? aGo lab construye software cliente puro para empresas. Conversemos.