Lógica en CSS
Lección 37 de 37 · 5 min
CSS empieza a tomar decisiones que antes pedían JavaScript: elegir un valor según una condición, definir funciones propias, saber qué lugar ocupa un elemento entre sus hermanos y leer números de los atributos del HTML. Casi todo es muy nuevo, así que cada parte dice dónde anda y cómo dejar un respaldo.
if(): un valor según una condición
if() elige un valor adentro de la misma declaración. Se escribe como una lista de condición: valor separadas por punto y coma; gana la primera que se cumple, y else cubre el resto. Hay tres tipos de condición:
- Una variable
style()Pregunta por una propiedad personalizada del mismo elemento, comostyle(--estado: cancelado). - La pantalla
media()Una media query en línea:media(width < 40rem). - El navegador
supports()Pregunta si el navegador entiende algo:supports(color: oklch(0 0 0)).
.boton {padding: if(media(width < 40rem): 8px 12px; else: 10px 16px);background: if(supports(color: oklch(0 0 0)): oklch(0.65 0.2 38);else: #e8590c);opacity: if(style(--deshabilitado: si): 0.5; else: 1);}
En el tablero, cada fila tiene --estado en su atributo style y el CSS decide el resto: el texto del estado (con content), su color y si la hora se tacha. La lectura de arriba sale de getComputedStyle: es lo que resolvió el navegador. El relleno usa media(), así que en un celular baja a 6px.
Tablero de salidas de Retiro con cuatro trenes; el de las 18:27 figura como Demorado
Salidas · Retiro, línea Mitre
18:09
| Hora | Destino | Andén | Estado |
|---|---|---|---|
| 18:12 | Tigre | 1 | |
| 18:20 | José León Suárez | 3 | |
| 18:27 | Bartolomé Mitre | 2 | |
| 18:35 | Tigre | 1 |
.salida > td {padding-block: if(media(width < 40rem): 6px; else: 10px);}.hora {text-decoration-line: if(style(--estado: cancelado): line-through; else: none);}.estado::after {content: if(style(--estado: demorado): "Demorado";style(--estado: cancelado): "Cancelado";else: "A horario");color: if(style(--estado: demorado): var(--aviso); else: inherit);}
<tr class="[--estado:demorado]"><td class="[text-decoration-line:if(style(--estado:cancelado):line-through;else:none)]">18:27</td><td>Bartolomé Mitre</td><td class="after:[content:if(style(--estado:demorado):'Demorado';style(--estado:cancelado):'Cancelado';else:'A_horario')] after:[color:if(style(--estado:demorado):var(--aviso);else:inherit)]"></td></tr>
style() mira el propio elemento. Es la diferencia con @container style(), que pregunta por el contenedor: con if() un elemento puede reaccionar a sus propias variables. Conviene cerrar siempre con else, así hay un valor cuando no se cumple ninguna condición.
Todavía no es Baseline: anda en Chrome y Edge desde la versión 137, y Firefox y Safari no lo soportan. Un navegador que no lo entiende descarta la declaración entera, así que alcanza con escribir antes una versión sin if():
.hora {text-decoration-line: none; /* respaldo */text-decoration-line: if(style(--estado: cancelado): line-through;else: none);}
@function: funciones propias
@function define una función que devuelve un valor, con el nombre de dos guiones como las variables. Los parámetros pueden tener tipo y valor por defecto, y returns dice qué tipo devuelve. Adentro, result es el valor de salida.
@function --escala(--paso <number>, --razon <number>: 1.25) returns <length> {result: calc(1rem * pow(var(--razon), var(--paso)));}h1 { font-size: --escala(3); } /* 1rem × 1.25³ = 31.25px */h2 { font-size: --escala(2, 1.333); }
El tipo no es decorativo: si le pasás algo que no es un número, la llamada es inválida y la propiedad vuelve a su valor heredado o inicial. Mové la razón: cada línea del espécimen fija solo su --paso, y la función hace la cuenta. En 1.25 la llamada no pasa la razón y usa el valor por defecto.
Delta
Islas del Tigre
Cómo llegar en lancha
Las lanchas colectivas salen de la Estación Fluvial de Tigre.
Horarios de octubre
@function --escala(--paso <number>, --razon <number>: 1.25) returns <length> {result: calc(1rem * pow(var(--razon), var(--paso)));}.especimen > * {font-size: calc(1rem * pow(1.25, var(--paso))); /* respaldo sin @function */font-size: --escala(var(--paso)); /* --razon usa su valor por defecto */}
<div><p class="[--paso:3] [font-size:--escala(var(--paso))]">Delta</p><p class="[--paso:2] [font-size:--escala(var(--paso))]">Islas del Tigre</p></div>
El cuerpo también acepta variables locales y reglas condicionales como @media, para que la función devuelva otra cosa según la pantalla:
@function --relleno() returns <length> {result: 16px;@media (width < 40rem) {result: 12px;}}
sibling-index() y sibling-count()
sibling-index() devuelve la posición del elemento entre sus hermanos, empezando en 1, y sibling-count() cuántos hermanos hay, contándolo a él. Son enteros, así que entran en calc(). Antes había que escribir esos números a mano en el HTML o calcularlos con JavaScript.
/* entrada escalonada: 40ms más por cada elemento */.item {transition-delay: calc((sibling-index() - 1) * 40ms);}/* repartir: cada uno ocupa una parte igual */.segmento {width: calc(100% / sibling-count());}
En la mesa, cada lugar gira 360deg / sibling-count() veces su posición. Sumá invitados: nadie recalcula nada, la cuenta cambia sola porque cambia la cantidad de hermanos. La entrada se escalona 40ms por lugar; con movimiento reducido, los lugares cambian sin desplazarse.
- 1Ana
- 2Bruno
- 3Carla
- 4Diego
- 5Elena
- 6Facu
.lugar {--angulo: calc(360deg / sibling-count() * (sibling-index() - 1));transform: rotate(var(--angulo)) translateY(-110px) rotate(calc(var(--angulo) * -1));transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1);transition-delay: calc((sibling-index() - 1) * 40ms);animation: aparecer 200ms cubic-bezier(0.22, 1, 0.36, 1) backwards;animation-delay: calc((sibling-index() - 1) * 40ms);}@keyframes aparecer {from { opacity: 0; }}@media (prefers-reduced-motion: reduce) {.lugar { transition: none; }}/* respaldo: índice y total escritos en el HTML */@supports not (order: sibling-index()) {.lugar {--angulo: calc(360deg / var(--total) * var(--indice));transition-delay: calc(var(--indice) * 40ms);animation-delay: calc(var(--indice) * 40ms);}}
<ol><li class="[--angulo:calc(360deg/sibling-count()*(sibling-index()-1))] [transform:rotate(var(--angulo))_translateY(-110px)_rotate(calc(var(--angulo)*-1))] transition-transform duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] delay-[calc((sibling-index()-1)*40ms)] motion-reduce:transition-none">Ana</li>…</ol>
Cuentan todos los hermanos que son elementos del HTML, incluso los ocultos. Por eso la mesa y las marcas naranjas del demo están fuera de la lista: si fueran hermanos de los lugares, cambiarían la cuenta.
Son Baseline 2026 (newly available): Chrome 138, Firefox 154 y Safari 26.2. Para versiones anteriores, el respaldo es el método de siempre: escribir --indice y --total en el HTML y usarlos adentro de @supports not (order: sibling-index()).
attr() con tipo
attr() existe desde siempre, pero solo devolvía texto para content. Ahora puede leer un atributo como número, longitud, color o ángulo, en cualquier propiedad. El tipo va en type(), y después de la coma, el valor si el atributo falta o no es válido.
.barra {--cacao: attr(data-cacao type(<number>), 0);width: calc(var(--cacao) * 1%);}/* con una unidad: data-ancho="240" se lee como 240px */.foto { width: attr(data-ancho px, auto); }
El HTML solo dice el número (data-cacao="75") y el CSS lo usa dos veces: para el largo de la barra y para el porcentaje de chocolate oscuro en color-mix(). Mové el control: cambia el atributo, no el estilo.
- Ecuador · Esmeraldas70%
- Madagascar · Sambirano64%
- Venezuela · Sur del Lago75%
- Perú · Piura85%
- Brasil · Bahía, con leche38%
.barra {width: 0; /* respaldo: sin barra; el porcentaje ya está escrito */}@supports (width: attr(data-cacao type(<number>))) {.barra {--cacao: attr(data-cacao type(<number>), 0);width: calc(var(--cacao) * 1%);background: color-mix(in oklch, #3b2417 calc(var(--cacao) * 1%), #e6cdb0);}}
<p>Venezuela · Sur del Lago</p><div class="w-0 supports-[width:attr(data-cacao_type(<number>))]:w-[calc(attr(data-cacao_type(<number>),0)*1%)] supports-[width:attr(data-cacao_type(<number>))]:bg-[color-mix(in_oklch,#3b2417_calc(attr(data-cacao_type(<number>),0)*1%),#e6cdb0)]" data-cacao="75"></div>
Por seguridad, un valor que viene de attr() no se puede usar para armar una url(). Para todo lo demás, sirve igual que una variable.
En Tailwind
Tailwind no tiene clases para nada de esto: van como propiedades arbitrarias entre corchetes, con guiones bajos en lugar de espacios.
| CSS | Tailwind |
|---|---|
| --estado: cancelado | [--estado:cancelado] |
| text-decoration-line: if(…) | [text-decoration-line:if(…)] |
| content en un pseudoelemento | after:[content:if(…)] |
| font-size: --escala(var(--paso)) | [font-size:--escala(var(--paso))] |
| transition-delay con sibling-index() | delay-[calc((sibling-index()-1)*40ms)] |
| width con attr() tipado | w-[calc(attr(data-cacao_type(<number>),0)*1%)] |
<tr class="[--estado:demorado]"><td class="[text-decoration-line:if(style(--estado:cancelado):line-through;else:none)]">18:27</td><td class="after:[content:if(style(--estado:demorado):'Demorado';else:'A_horario')]"></td></tr><li class="delay-[calc((sibling-index()-1)*40ms)] transition-opacity">Ana</li>
Logic in CSS
Lesson 37 of 37 · 5 min
CSS is starting to make decisions that used to need JavaScript: picking a value based on a condition, defining your own functions, knowing an element's place among its siblings and reading numbers from HTML attributes. Most of it is very new, so each part says where it works and how to leave a fallback.
if(): a value based on a condition
if() picks a value inside the declaration itself. It's a list of condition: value pairs separated by semicolons; the first one that holds wins, and else covers the rest. There are three kinds of condition:
- A variable
style()Asks about a custom property on the same element, likestyle(--status: cancelled). - The screen
media()An inline media query:media(width < 40rem). - The browser
supports()Asks whether the browser understands something:supports(color: oklch(0 0 0)).
.button {padding: if(media(width < 40rem): 8px 12px; else: 10px 16px);background: if(supports(color: oklch(0 0 0)): oklch(0.65 0.2 38);else: #e8590c);opacity: if(style(--disabled: yes): 0.5; else: 1);}
On the board, each row has --status in its style attribute and the CSS decides the rest: the status text (through content), its colour and whether the time is struck out. The readout comes from getComputedStyle: it's what the browser resolved. The padding uses media(), so on a phone it drops to 6px.
Retiro departures board with four trains; the 18:27 shows as Delayed
Departures · Retiro, Mitre line
18:09
| Time | To | Platform | Status |
|---|---|---|---|
| 18:12 | Tigre | 1 | |
| 18:20 | José León Suárez | 3 | |
| 18:27 | Bartolomé Mitre | 2 | |
| 18:35 | Tigre | 1 |
.departure > td {padding-block: if(media(width < 40rem): 6px; else: 10px);}.time {text-decoration-line: if(style(--status: cancelled): line-through; else: none);}.status::after {content: if(style(--status: delayed): "Delayed";style(--status: cancelled): "Cancelled";else: "On time");color: if(style(--status: delayed): var(--warning); else: inherit);}
<tr class="[--status:delayed]"><td class="[text-decoration-line:if(style(--status:cancelled):line-through;else:none)]">18:27</td><td>Bartolomé Mitre</td><td class="after:[content:if(style(--status:delayed):'Delayed';style(--status:cancelled):'Cancelled';else:'On_time')] after:[color:if(style(--status:delayed):var(--warning);else:inherit)]"></td></tr>
style() looks at the element itself. That's the difference from @container style(), which asks about the container: with if() an element can react to its own variables. Always end with else, so there's a value when no condition holds.
It's not Baseline yet: Chrome and Edge support it since version 137, and Firefox and Safari don't. A browser that doesn't understand it drops the whole declaration, so writing a version without if() just before it is enough:
.time {text-decoration-line: none; /* fallback */text-decoration-line: if(style(--status: cancelled): line-through;else: none);}
@function: your own functions
@function defines a function that returns a value, named with two dashes like a custom property. Parameters can have a type and a default, and returns says what type comes out. Inside, result is the output.
@function --scale(--step <number>, --ratio <number>: 1.25) returns <length> {result: calc(1rem * pow(var(--ratio), var(--step)));}h1 { font-size: --scale(3); } /* 1rem × 1.25³ = 31.25px */h2 { font-size: --scale(2, 1.333); }
The type isn't decoration: pass something that isn't a number and the call is invalid, so the property falls back to its inherited or initial value. Move the ratio: each line of the specimen only sets its --step, and the function does the maths. At 1.25 the call leaves the ratio out and uses the default.
Delta
Tigre islands
Getting there by boat
Passenger boats leave from the Tigre river station.
October timetable
@function --scale(--step <number>, --ratio <number>: 1.25) returns <length> {result: calc(1rem * pow(var(--ratio), var(--step)));}.specimen > * {font-size: calc(1rem * pow(1.25, var(--step))); /* fallback without @function */font-size: --scale(var(--step)); /* --ratio uses its default */}
<div><p class="[--step:3] [font-size:--scale(var(--step))]">Delta</p><p class="[--step:2] [font-size:--scale(var(--step))]">Tigre islands</p></div>
The body also accepts local variables and conditional rules such as @media, so the function can return something else depending on the screen:
@function --gutter() returns <length> {result: 16px;@media (width < 40rem) {result: 12px;}}
sibling-index() and sibling-count()
sibling-index() returns the element's position among its siblings, starting at 1, and sibling-count() how many siblings there are, itself included. They're integers, so they work inside calc(). Before, you had to write those numbers into the HTML or work them out with JavaScript.
/* staggered entrance: 40ms more per item */.item {transition-delay: calc((sibling-index() - 1) * 40ms);}/* share the space: each takes an equal part */.segment {width: calc(100% / sibling-count());}
At the table, each seat turns 360deg / sibling-count() times its position. Add guests: nothing recalculates anything, the maths changes on its own because the number of siblings changes. The entrance is staggered 40ms per seat; with reduced motion, the seats change place without moving.
- 1Ana
- 2Bruno
- 3Carla
- 4Diego
- 5Elena
- 6Facu
.seat {--angle: calc(360deg / sibling-count() * (sibling-index() - 1));transform: rotate(var(--angle)) translateY(-110px) rotate(calc(var(--angle) * -1));transition: transform 250ms cubic-bezier(0.22, 1, 0.36, 1);transition-delay: calc((sibling-index() - 1) * 40ms);animation: fade-in 200ms cubic-bezier(0.22, 1, 0.36, 1) backwards;animation-delay: calc((sibling-index() - 1) * 40ms);}@keyframes fade-in {from { opacity: 0; }}@media (prefers-reduced-motion: reduce) {.seat { transition: none; }}/* fallback: index and total written in the HTML */@supports not (order: sibling-index()) {.seat {--angle: calc(360deg / var(--total) * var(--index));transition-delay: calc(var(--index) * 40ms);animation-delay: calc(var(--index) * 40ms);}}
<ol><li class="[--angle:calc(360deg/sibling-count()*(sibling-index()-1))] [transform:rotate(var(--angle))_translateY(-110px)_rotate(calc(var(--angle)*-1))] transition-transform duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] delay-[calc((sibling-index()-1)*40ms)] motion-reduce:transition-none">Ana</li>…</ol>
They count every sibling that is an element in the HTML, hidden ones included. That's why the table and the orange marks in the demo sit outside the list: as siblings of the seats, they would change the count.
They're Baseline 2026 (newly available): Chrome 138, Firefox 154 and Safari 26.2. For older versions the fallback is the usual approach: write --index and --total into the HTML and use them inside @supports not (order: sibling-index()).
Typed attr()
attr() has always existed, but it only returned text for content. Now it can read an attribute as a number, length, colour or angle, in any property. The type goes in type(), and after the comma, the value to use if the attribute is missing or invalid.
.bar {--cocoa: attr(data-cocoa type(<number>), 0);width: calc(var(--cocoa) * 1%);}/* with a unit: data-width="240" reads as 240px */.photo { width: attr(data-width px, auto); }
The HTML only states the number (data-cocoa="75") and the CSS uses it twice: for the bar's length and for the share of dark chocolate in color-mix(). Move the control: it changes the attribute, not the style.
- Ecuador · Esmeraldas70%
- Madagascar · Sambirano64%
- Venezuela · Sur del Lago75%
- Peru · Piura85%
- Brazil · Bahia, milk38%
.bar {width: 0; /* fallback: no bar; the percentage is already written */}@supports (width: attr(data-cocoa type(<number>))) {.bar {--cocoa: attr(data-cocoa type(<number>), 0);width: calc(var(--cocoa) * 1%);background: color-mix(in oklch, #3b2417 calc(var(--cocoa) * 1%), #e6cdb0);}}
<p>Venezuela · Sur del Lago</p><div class="w-0 supports-[width:attr(data-cocoa_type(<number>))]:w-[calc(attr(data-cocoa_type(<number>),0)*1%)] supports-[width:attr(data-cocoa_type(<number>))]:bg-[color-mix(in_oklch,#3b2417_calc(attr(data-cocoa_type(<number>),0)*1%),#e6cdb0)]" data-cocoa="75"></div>
For security, a value that comes from attr() can't be used to build a url(). For everything else, it works like a custom property.
In Tailwind
Tailwind has no classes for any of this: it all goes in brackets as arbitrary properties, with underscores instead of spaces.
| CSS | Tailwind |
|---|---|
| --status: cancelled | [--status:cancelled] |
| text-decoration-line: if(…) | [text-decoration-line:if(…)] |
| content on a pseudo-element | after:[content:if(…)] |
| font-size: --scale(var(--step)) | [font-size:--scale(var(--step))] |
| transition-delay with sibling-index() | delay-[calc((sibling-index()-1)*40ms)] |
| width with typed attr() | w-[calc(attr(data-cocoa_type(<number>),0)*1%)] |
<tr class="[--status:delayed]"><td class="[text-decoration-line:if(style(--status:cancelled):line-through;else:none)]">18:27</td><td class="after:[content:if(style(--status:delayed):'Delayed';else:'On_time')]"></td></tr><li class="delay-[calc((sibling-index()-1)*40ms)] transition-opacity">Ana</li>