Saltar al contenido

Sileo

Notificaciones toast con morphing gooey y spring physics. Core vanilla, cero dependencias, y toda la animación en CSS. Funciona con cualquier framework; trae adaptador para Vue 3.

Ver la demo interactiva →  ·  Código en GitHub ↗  ·  Paquete en npm ↗

Qué es

Sileo muestra una cápsula con el estado y el título. Al pasar el cursor por encima se despliega el mensaje: la cápsula y el panel se funden en una sola forma, sin costuras, gracias a un filtro SVG de metaballs.

Cuando hay varias notificaciones a la vez forman una fila de pestañas solapadas a la altura del título. Solo la enfocada muestra su texto; las demás quedan como círculos con su icono. Ver La fila de tabs.

El JavaScript solo mantiene el estado, crea el DOM, mide dos cosas (ancho de la cápsula y alto del panel) y coloca las tabs. Todo el movimiento —el spring, el morphing, la entrada y la salida— es CSS.

Instalación

Con bundler (npm, pnpm, yarn)

npm i sileojs
import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";
La hoja de estilos es obligatoria. El paquete no la inyecta solo: sin sileojs/styles.css no hay geometría ni animación y los toasts salen sin formato.

Sin build, desde el navegador

Es un módulo ES sin dependencias, así que se puede importar tal cual:

<link rel="stylesheet" href="/ruta/a/sileojs/src/sileo.css">

<script type="module">
  import { sileo, createToaster } from "/ruta/a/sileojs/src/sileo.js";
  createToaster({ position: "top-right" });
  sileo.success({ title: "Listo" });
</script>
Los módulos ES necesitan un servidor que sirva los .js con Content-Type: text/javascript. Ojo con python -m http.server: en Windows los sirve como text/plain y el navegador los rechaza. Con npx serve . funciona.

Qué trae el paquete

ImportContenido
sileojsCore vanilla: sileo, createToaster, getToaster
sileojs/vueAdaptador Vue 3: SileoPlugin, SileoToaster, useSileo
sileojs/styles.cssLa hoja de estilos

Inicio rápido

Dos pasos: montar el toaster una vez y llamar a sileo.*() desde donde sea.

import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";

// una sola vez, al arrancar la app
createToaster({ position: "top-right", theme: "system" });

// desde cualquier parte
sileo.success({
  title: "Guardado",
  description: "Tus cambios se sincronizaron con el servidor.",
});

createToaster() es opcional: si no lo llamas, el primer sileo.*() monta uno con los valores por defecto. Llámalo si quieres fijar posición, tema o márgenes.

JavaScript / vanilla

El core no sabe nada de frameworks. El patrón es siempre el mismo: montar el toaster al arrancar, destruirlo si tu vista desaparece.

import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";

const toaster = createToaster({
  position: "bottom-center",
  theme: "system",
  offset: 24,
});

document.querySelector("#guardar").addEventListener("click", async () => {
  await sileo.promise(guardar(), {
    loading: { id: "guardar", title: "Guardando", description: "Un momento…" },
    success: () => ({ title: "Guardado", description: "Todo listo." }),
    error: (err) => ({ title: "Error", description: String(err.message) }),
  });
});

// al salir de la página / desmontar el widget
toaster.destroy();

Vue 3

Hay dos formas. La global es la más cómoda si vas a usarlo en toda la app.

Sin componente: plugin y $sileo global

El plugin monta el toaster al arrancar y deja $sileo disponible en todas las plantillas. No hace falta poner ningún componente ni importar nada en cada archivo.

// main.js
import { createApp } from "vue";
import { SileoPlugin } from "sileojs/vue";
import "sileojs/styles.css";
import App from "./App.vue";

createApp(App)
  .use(SileoPlugin, {
    position: "top-right",
    theme: "light",
    options: { roundness: 14, styles: { toast: "top-90" } },
  })
  .mount("#app");
<!-- cualquier componente, sin imports -->
<template>
  <button @click="$sileo.success({ title: 'Guardado' })">
    Guardar
  </button>
</template>
$sileo es una globalProperty: solo existe dentro de la plantilla. En <script setup> usa import { sileo } from "sileojs" o inject("sileo") — el plugin también lo provee.

Al plugin le puedes pasar las mismas opciones que a createToaster(). Con { mount: false } registra $sileo y el componente, pero no monta nada: el toaster lo montas tú.

Con componente

Útil si quieres que el toaster viva y muera con una vista concreta, o cambiar sus props de forma reactiva.

<script setup>
import { SileoToaster, useSileo } from "sileojs/vue";
import "sileojs/styles.css";

const sileo = useSileo();
</script>

<template>
  <SileoToaster position="top-right" theme="system" />

  <button @click="sileo.success({ title: 'Guardado' })">
    Guardar
  </button>
</template>

Props: position, theme, offset, visibleToasts, options y styles (atajo de options.styles). Cambiarlas reconfigura el toaster en caliente.

<SileoToaster position="top-right" theme="light" :styles="{ toast: 'top-90' }" />

Config reactiva: useSileoConfig()

Devuelve un objeto reactivo compartido por toda la app. Mutarlo reconfigura el toaster al vuelo — tema, posición y estilos incluidos, y también sobre los toasts que ya están en pantalla. En plantillas está como $sileoConfig.

<script setup>
import { useSileoConfig } from "sileojs/vue";

const cfg = useSileoConfig();

const oscuro = () => (cfg.theme = "dark");
const abajo  = () => (cfg.position = "bottom-center");
const gordo  = () => (cfg.styles.title = "text-lg font-bold");
</script>

<template>
  <select v-model="cfg.theme">
    <option>light</option><option>dark</option><option>system</option>
  </select>
</template>
CampoQué es
cfg.positionUna de las 6 posiciones
cfg.themelight | dark | system
cfg.offsetNúmero/string o { top, right, bottom, left }
cfg.visibleToastsTabs visibles en la fila
cfg.optionsDefaults de todos los toasts
cfg.stylesEstilos por parte (ver Estilos por parte)
El toaster es único para toda la página. Si ya lo montó el plugin (u otro createToaster()), el componente lo reutiliza y no lo destruye al desmontarse: solo destruye el que creó él.

React

import { useEffect } from "react";
import { sileo, createToaster } from "sileojs";
import "sileojs/styles.css";

export default function App() {
  useEffect(() => {
    const toaster = createToaster({ position: "top-right", theme: "system" });
    return () => toaster.destroy();
  }, []);

  return (
    <button onClick={() => sileo.success({ title: "Guardado" })}>
      Guardar
    </button>
  );
}
destroy usa this: llámalo sobre el toaster (() => toaster.destroy()). Si pasas createToaster(…).destroy suelto como función de limpieza, peta al desmontar.

En React 18 con StrictMode el efecto se monta, se limpia y se vuelve a montar en desarrollo. No pasa nada: el segundo createToaster() crea uno nuevo tras el destroy() del primero.

Svelte

<script>
  import { onMount } from "svelte";
  import { sileo, createToaster } from "sileojs";
  import "sileojs/styles.css";

  onMount(() => {
    const toaster = createToaster({ position: "top-right" });
    return () => toaster.destroy();
  });
</script>

<button on:click={() => sileo.success({ title: "Guardado" })}>
  Guardar
</button>

Angular

La hoja de estilos va en angular.json:

"styles": ["node_modules/sileojs/src/sileo.css"]
import { Component, OnInit, OnDestroy } from "@angular/core";
import { sileo, createToaster } from "sileojs";

@Component({
  selector: "app-root",
  template: `<button (click)="guardar()">Guardar</button>`,
})
export class AppComponent implements OnInit, OnDestroy {
  private toaster?: ReturnType<typeof createToaster>;

  ngOnInit() {
    this.toaster = createToaster({ position: "top-right" });
  }

  ngOnDestroy() {
    this.toaster?.destroy();
  }

  guardar() {
    sileo.success({ title: "Guardado" });
  }
}
TypeScript: el paquete todavía no trae .d.ts. Activa allowJs o declara el módulo: declare module "sileojs";

SSR y sin build

El core toca document en cuanto montas el toaster, así que en Next, Nuxt, SvelteKit o Astro hay que montarlo solo en el cliente: dentro de useEffect, onMounted, onMount o un <script> de cliente. Importar el módulo por sí solo no ejecuta nada.

Sin bundler puedes resolver el nombre del paquete con un import map:

<script type="importmap">
{ "imports": { "sileojs": "/vendor/sileojs/src/sileo.js" } }
</script>

<script type="module">
  import { sileo, createToaster } from "sileojs";
  createToaster();
  window.sileo = sileo;   // si lo quieres global de verdad
</script>

API · sileo.*

MétodoDevuelveQué hace
sileo.show(opts)idUsa opts.type (o opts.state) como estado
sileo.success(opts)idAtajos por estado
sileo.error(opts)id
sileo.warning(opts)id
sileo.info(opts)id
sileo.action(opts)id
sileo.loading(opts)id
sileo.promise(p, opts)la promesaloadingsuccess / error / action
sileo.update(id, opts)Cambia un toast vivo: colapsa, cambia el contenido y sigue
sileo.dismiss(id)Salida animada
sileo.clear(position?)Limpia todo, o solo una posición
sileo.configure(opts)el toasterReconfigura en caliente (lo monta si no hay)
sileo.setTheme(t)el toasterAtajos de configure
sileo.setPosition(p)el toaster
sileo.setStyles(s)el toaster
sileo.getConfig()la configLa config actual, o null si no hay toaster
Cada llamada es una notificación. Sin id se le asigna uno único, así que dos llamadas seguidas se ven las dos y conviven en la fila. Pasa un id propio cuando quieras que la siguiente llamada reemplace a esa misma notificación —es como se sigue una tarea (progreso, reintentos, sileo.promise(), sileo.update()).

sileo.promise()

Acepta una promesa o una función que la devuelva. Muestra el estado loading (sin auto-cierre y sin desplegarse) y lo cambia al resolverse. Devuelve la misma promesa, así que puedes seguir encadenando —y capturando— con normalidad.

sileo.promise(() => subirArchivo(file), {
  loading: { id: "subida", title: "Subiendo", description: "Enviando archivo…" },
  success: (data) => ({ title: "Subido", description: data.nombre }),
  error:   (err)  => ({ title: "Falló", description: String(err.message) }),

  // opcional: en vez de success, un estado "action" con botón
  action: (data) => ({
    title: "Reporte listo",
    description: "Ya puedes descargarlo.",
    button: { title: "Descargar", onClick: () => descargar(data.url) },
  }),
});

Opciones del toast

OpciónTipoPor defectoDescripción
idstringuno único por llamadaIdentidad. Mismo id = mismo toast (lo reemplaza)
titlestringel estadoTexto de la cápsula
descriptionstring | Node | {html}El mensaje del panel. Sin él, el toast no se despliega
type / statesuccess | loading | error | warning | info | action"success"Color e icono
positionuna de las 6la del toasterPuede diferir por toast
durationnumber | null6000ms hasta cerrarse. null = se queda
iconNode | {html} | stringel del estadoUn string se inserta como texto; para un SVG usa { html }
button{ title, onClick }Botón dentro del panel
styles{ [parte]: string | objeto }Clases y/o CSS por parte, ver Estilos por parte
classNamestringClase para la raíz del toast
fillstringsegún temaColor de la cápsula y el panel
roundnessnumber16Radio; también escala el blur del gooey
autopilotfalse | { expand, collapse }150 / 4000 msVer Autopilot

Contenido enriquecido

description e icon aceptan un nodo del DOM o { html }. Un string siempre se inserta como texto plano, así que no hay riesgo de inyección al pintar datos de usuario.

Si pasas un nodo, es tuyo: puedes guardarlo, ponerle controles y cambiar su contenido en caliente. Como el toast reutiliza el mismo nodo, tocarlo no rehace la notificación ni cierra el panel — al revés que sileo.update(), que sí la rehace. La demo lo usa para un reproductor con botones de anterior/siguiente que cambian el texto de esa misma notificación.
sileo.info({
  id: "gracias",
  title: "Gracias",
  icon: { html: '<svg viewBox="0 0 24 24" …></svg>' },
  description: nodoDelDom,
  styles: { title: "mi-titulo", description: "mi-texto" },
});

Opciones del toaster

const toaster = createToaster({
  position: "top-right",     // las 6 posiciones
  theme: "system",           // "light" | "dark" | "system"
  offset: 24,                // número, string, o { top, right, bottom, left }
  visibleToasts: 3,          // tabs visibles en la fila
  options: {                 // valores por defecto para todos los toasts
    duration: 4000,
    roundness: 20,
    styles: { toast: "top-90" },
  },
  styles: { title: "font-bold" },  // atajo de options.styles
  container: document.body,  // dónde montar los viewports
});

toaster.set({ position: "bottom-center" });   // reconfigurar en caliente
toaster.set({ theme: "dark" });
toaster.set({ styles: { badge: "ring-1" } }); // se mezcla con lo que hubiera
toaster.config;                               // la config actual
toaster.destroy();                            // desmontar y limpiar
PosiciónDónde aparece
top-left · top-center · top-rightArriba; el panel se abre hacia abajo
bottom-left · bottom-center · bottom-rightAbajo; el panel se abre hacia arriba

Cada posición es un stack independiente, con su propia fila de tabs y su propio foco.

Exports

ExportQué es
sileo (también default)La API para lanzar toasts
createToaster(opts)Monta el toaster (o reconfigura el que haya) y lo devuelve
getToaster()El toaster montado, o null
configure(opts)Reconfigura el toaster en caliente desde cualquier sitio
getConfig()La config actual, o null
STYLE_PARTSLos nombres de las partes estilables, como array
ToasterLa clase, por si quieres instanciarla tú
dismissToast(id)Igual que sileo.dismiss
SILEO_POSITIONS · SILEO_STATESLos valores válidos, como arrays
STATE_ICONLos iconos por defecto (SVG en string) por si quieres reusarlos

Y desde sileojs/vue:

ExportQué es
SileoPlugin (también default)Monta el toaster, registra el componente y $sileo
SileoToasterEl componente
useSileo()Devuelve sileo, para composables
useSileoConfig()La config reactiva (tema, posición, estilos)

Interacción

  • Cursor encima → se abre el panel y se pausa el auto-cierre de todos los toasts.
  • Cursor ya quieto ahí → cuenta igual. Si el toast nace debajo del puntero no hay pointerenter que valga, así que Sileo mira dónde está el cursor al montar y al recolocar: el panel se queda abierto hasta que salga.
  • Cursor a otra tab → el enfoque salta a esa: se ensancha, muestra su título y abre su mensaje.
  • Arrastrar en vertical más de 30 px → se descarta.
  • Teclado → cada toast es enfocable; al recibir el foco se comporta como si tuviera el cursor encima.
  • prefers-reduced-motion → se desactiva todo el movimiento.

El toast de estado loading nunca se despliega ni se auto-cierra: está esperando a que lo actualices.

Ninguna llamada se pierde. Sin id, cada una es su propia notificación y conviven en la fila. Con el mismo id la nueva reemplaza a la anterior, pero se ve igual: vuelve al frente de la fila —aunque estuviera enterrada bajo el corte del stack o en plena salida—, la cabecera repite su entrada aunque el texto sea idéntico, y su tiempo arranca de cero.

La fila de tabs

Varias notificaciones en la misma posición no se apilan como lista: forman una fila de pestañas solapadas a la altura del título. Solo baja el panel de la enfocada.

  • Las tabs de atrás enseñan solo su icono, y el icono se arrima al borde por el que asoma la tab —en las posiciones de la derecha la fila crece hacia dentro y asoma el lado izquierdo; en las de la izquierda y el centro, el derecho—. Vale igual en reposo y con el cursor encima.
  • El enfoque es la tab del borde de la pantalla, el toast más nuevo. Un toast nuevo se lleva el enfoque.
  • Al entrar el cursor la baraja se abre: aparecen todas las notificaciones —el corte de tres es solo para el reposo— y la del frente abre su panel.
  • Solo la tab enfocada se ensancha; las demás se quedan en su icono. Pero se ensancha siempre al mismo ancho, el de la más ancha del stack: así lo que crece compensa lo que se corre y la tab que señalas no se escapa del puntero. Si midiera lo suyo, al enfocar una de título corto el foco saltaría a la vecina.
  • La enfocada conserva su z-index natural: va por debajo de las que tiene delante y por encima de las de atrás, como una pestaña. Si se pusiera encima de todo, al enfocar una del medio se comería a las que quedan entre ella y el borde. Su contenido arranca pasado el trozo que le tapa la de delante, para que el icono y el título se vean enteros.
  • Si la fila no cabe, se recorta primero el ancho de la enfocada y luego lo que asoma de cada icono: una tab fuera del viewport quedaría fuera de la zona de hover.
  • En reposo se ven 3 tabs (--sileo-stack-max o visibleToasts) y, si hay más, aparece un + al final del mazo: solo avisa de que quedan otras, el número exacto se ve al abrir la fila. La enfocada nunca se oculta, aunque lleguen toasts nuevos.

Las posiciones salen de una sola recurrencia, del borde hacia dentro:

x[0]     = 0
x[i+1]   = x[i] + ancho(i) - tab-overlap

// en reposo
ancho(i) = (i enfocada ? ancho de su cápsula : alto del toast)

// la enfocada, siempre al mismo ancho
ancho(i) = (i enfocada ? max(ancho de cada cápsula) : alto del toast)
//          ^ acotado para que la fila quepa en el viewport

Autopilot

Un toast nuevo se abre solo un momento para que se lea el mensaje, y se cierra pasados unos segundos. Por defecto se abre a los 150 ms y se cierra a los 4 s (siempre dentro de su duration).

sileo.info({ title: "Sin autopilot", autopilot: false });

sileo.info({
  title: "A mi ritmo",
  description: "Se abre en seguida y se queda 8 segundos.",
  duration: 10000,
  autopilot: { expand: 0, collapse: 8000 },
});
Mientras el cursor está dentro del stack el autopilot deja de contar: no cierra el panel que estás señalando. Al salir el cursor, todo vuelve a su reposo.

Variables CSS

Todo se ajusta con custom properties; sobrescríbelas donde quieras.

:root {
  /* forma */
  --sileo-width: 350px;
  --sileo-height: 40px;
  --sileo-roundness: 16px;
  --sileo-gap: 12px;              /* separación del borde de la pantalla */

  /* movimiento */
  --sileo-duration: 600ms;
  --sileo-spring-easing: linear(…);   /* el spring, ya linealizado */
  --sileo-ease-flat: cubic-bezier(0.32, 0.72, 0, 1);

  /* fila de tabs */
  --sileo-tab-overlap: 18px;      /* solape en reposo */
  --sileo-tab-overlap-hot: 12px;  /* solape con el cursor dentro */
  --sileo-stack-max: 3;           /* tabs visibles */
  --sileo-shadow: drop-shadow(0 2px 8px rgb(0 0 0 / 0.18));

  /* color */
  --sileo-fill: #ffffff;
  --sileo-state-success: oklch(0.723 0.219 142.136);
  --sileo-state-error:   oklch(0.637 0.237 25.331);
  --sileo-state-warning: oklch(0.795 0.184 86.047);
  --sileo-state-info:    oklch(0.685 0.169 237.323);
  --sileo-state-action:  oklch(0.623 0.214 259.815);
  --sileo-state-loading: oklch(0.556 0 0);
}
La sombra es lo que separa una tab de la que tiene detrás, así que depende del relleno: con cápsulas claras basta una sombra oscura, pero con theme: "light" la cápsula es oscura y una sombra negra sobre otra cápsula oscura no separa nada — ahí se le suma un filo claro. Si cambias --sileo-fill a mano, ajusta también --sileo-shadow.
--sileo-height, --sileo-tab-overlap y --sileo-stack-max están declaradas con @property. El JS las lee ya resueltas a px, así que puedes escribirlas en cualquier unidad y el CSS sigue siendo la única fuente de las medidas.

Temas

Con theme el toaster elige el relleno: en tema claro la cápsula es oscura y al revés. "system" sigue a prefers-color-scheme y reacciona a los cambios.

createToaster({ theme: "system" });   // "light" | "dark" | "system"

Si no pasas theme, manda el CSS: el relleno es --sileo-fill. Y siempre puedes fijarlo por toast con fill.

Estilos por parte

styles es un objeto parte → estilo. No hay nada de ningún framework en él: lo que le pasas son clases, propiedades CSS, o las dos. Va por toast (styles) o para todos (options.styles del toaster).

ParteNodo
viewportEl contenedor de esa posición
toastLa raíz del toast
canvas · pill · bodyLas capas del gooey
header · badge · titleLa cabecera (la tab)
content · description · buttonEl panel abierto
countEl indicador +N

Tres formas, y se pueden mezclar entre partes:

createToaster({
  position: "top-right",
  options: {
    roundness: 14,
    styles: { toast: "top-90" },   // para todos los toasts
  },
});

sileo.success({
  title: "Guardado",
  description: "Todo listo.",
  styles: {
    toast: "rounded-2xl shadow-lg",                     // clases
    description: { color: "#64748b", fontSize: "13px" }, // propiedades CSS
    badge: { class: "ring-1", style: { "--sileo-roundness": "20px" } },
  },
});
camelCase y kebab-case valen igual, y las custom properties (--sileo-*) también: desde styles se puede tocar cualquier variable del CSS sin escribir una hoja aparte. Los estilos se mezclan por parte y gana el toast: si el toaster pone styles.toast y la llamada también, se usa el de la llamada. Poner null en una parte la deja limpia.

Si prefieres apuntar al DOM, cada parte tiene su atributo: [data-sileo-toast], [data-sileo-header], [data-sileo-badge], [data-sileo-title], [data-sileo-description], [data-sileo-button].

Si tu hoja tiene reglas sueltas del tipo button:hover { … }, ten en cuenta que la raíz del toast es un <button>. Sileo se defiende subiendo la especificidad de sus propios estilos, pero acostumbra a scopear los tuyos.

Cambios en caliente

Tema, posición y estilos se pueden cambiar en cualquier momento, y lo que ya está en pantalla se reajusta: los toasts cambian de relleno, se mudan al viewport nuevo y se repintan con los estilos nuevos. configure() no necesita el toaster ni ningún adaptador, así que sirve igual en vanilla, React, Svelte o Angular.

import { configure, sileo } from "sileojs";

configure({ theme: "dark" });                 // o sileo.setTheme("dark")
configure({ position: "bottom-center" });     // o sileo.setPosition(…)
configure({ styles: { toast: "top-90" } });   // o sileo.setStyles(…)

sileo.getConfig();  // { position, theme, resolvedTheme, offset, options, styles }
Qué pasaCon qué
Los estilos se mezclan con los que hubieraconfigure({ styles })
Se borra una parteconfigure({ styles: { title: null } })
Se borran todos los defaultsconfigure({ options: null })
Se sustituyen en bloqueconfigure({ styles, replace: true })
Un toast que pidió su propia position se queda donde está; los demás siguen la del toaster. Y si el tema pasa a "system", el toaster vuelve a escuchar a prefers-color-scheme.

Accesibilidad

  • Cada posición es un <section role="status" aria-live="polite">: los lectores de pantalla anuncian el toast al aparecer, sin interrumpir.
  • La raíz de cada toast es un <button> enfocable, con aria-label igual al título.
  • Al recibir el foco por teclado se abre el panel, igual que con el cursor.
  • Las tabs que pasan del máximo visible se ocultan con visibility: hidden, así que salen del orden de tabulación.
  • Con prefers-reduced-motion: reduce no queda ninguna animación ni transición.

Compatibilidad

Chrome/Edge 113+, Safari 16.4+ y Firefox 128+. Sin build step y sin dependencias.

Se apoya enPara qué
linear()El spring, como easing nativa
@propertyQue el JS lea las medidas ya resueltas
translate / scaleColocar las tabs sin tocar transform
oklch(), color-mix()Los colores de estado y sus fondos
Filtros SVGEl morphing gooey
ResizeObserverMedir cápsula y panel

Cómo funciona

El efecto de fundido son dos rectángulos y un filtro SVG:

  • La cápsula y el panel son dos div con border-radius, dentro de una capa filtrada.
  • El filtro difumina y luego sube el contraste del alfa (×20 −10): eso convierte el blur en un borde duro, y ahí es donde dos formas cercanas se funden.
  • El color del puente no sale del blur —en sRGB sin premultiplicar lo transparente es negro y saldría gris—: se inunda con --sileo-fill y se recorta con el alfa del umbral.
  • Al abrirse, la cápsula crece hacia el panel para provocar el solape.
  • Las posiciones de abajo reusan la misma geometría con scaleY(-1).
  • La sombra va después del filtro en la cadena, sobre la silueta ya fundida.

El JS solo publica medidas —--_pw (ancho de la cápsula), --_ch (alto del contenido), --_tx (sitio en la fila)— y el CSS deriva de ahí toda la geometría con calc() y max().