← AE Lab
Barra de navegación con gota líquida
El indicador de la pestaña activa es una gota que se estira como un líquido hasta la pestaña que pulsas. En el móvil va abajo; en el ordenador sube arriba y enseña los textos.
// CÓMO ESTÁ HECHO
- Una gota verde, una «cola» que la persigue con retraso y un cuello entre las dos que adelgaza al estirarse.
- Un filtro SVG las desenfoca y vuelve a endurecer el borde: al acercarse, se funden en una sola.
- Un muelle con rozamiento mueve la gota; cuanto más rápida va, más se estira en horizontal y se aplasta en vertical.
- Pestañas accesibles (role=tab, flechas del teclado) y deslizar la pantalla para cambiar.
// EL CÓDIGO
<div class="app">
<main class="vistas" id="vistas">
<section class="vista" id="v0" role="tabpanel" aria-labelledby="t0">
<h1>Inicio<span>_</span></h1>
<p>Pulsa otra pestaña o desliza la pantalla: la gota se estira hasta allí como un líquido.</p>
</section>
<section class="vista" id="v1" role="tabpanel" aria-labelledby="t1" data-lado="der">
<h1>Buscar<span>_</span></h1>
<p>Cuanto más lejos está la pestaña, más se estira la gota.</p>
</section>
<section class="vista" id="v2" role="tabpanel" aria-labelledby="t2" data-lado="der">
<h1>Guardado<span>_</span></h1>
<p>Una cola la persigue y un cuello de líquido las une; al estirarse, el cuello se adelgaza.</p>
</section>
<section class="vista" id="v3" role="tabpanel" aria-labelledby="t3" data-lado="der">
<h1>Perfil<span>_</span></h1>
<p>También funciona con las flechas del teclado.</p>
</section>
</main>
<nav class="barra" id="barra" aria-label="Navegación principal">
<div class="liquido" aria-hidden="true">
<div class="cuello" id="cuello"></div>
<div class="gota cola" id="cola"></div>
<div class="gota" id="gota"></div>
</div>
<div class="tabs" role="tablist">
<button class="tab" id="t0" role="tab" aria-selected="true" aria-controls="v0">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span>Inicio</span>
</button>
<button class="tab" id="t1" role="tab" aria-selected="false" aria-controls="v1" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg><span>Buscar</span>
</button>
<button class="tab" id="t2" role="tab" aria-selected="false" aria-controls="v2" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4z"/></svg><span>Guardado</span>
</button>
<button class="tab" id="t3" role="tab" aria-selected="false" aria-controls="v3" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21c1.5-4 4.5-6 8-6s6.5 2 8 6"/></svg><span>Perfil</span>
</button>
</div>
</nav>
</div>
<!-- Filtro «goo»: desenfoca y vuelve a endurecer el borde -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<filter id="goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="9" result="blur"/>
<feColorMatrix in="blur" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9"/>
</filter>
</svg>
:root {
--fondo: #05070A;
--superficie: #0D1410;
--borde: #1B2A21;
--verde: #00FF7F;
--blanco: #E8F5EE;
--gris: #7C8A82;
}
* { box-sizing: border-box; margin: 0; }
html, body { height: 100%; }
body {
background: var(--fondo);
color: var(--blanco);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
.app { height: 100dvh; display: flex; flex-direction: column; }
/* Las cuatro pantallas: se deslizan hacia el lado de la pestaña elegida */
.vistas { flex: 1; position: relative; overflow: hidden; touch-action: pan-y; }
.vista {
position: absolute; inset: 0;
display: grid; place-content: center; justify-items: center; gap: 14px;
padding: 24px; text-align: center;
transition: transform .55s cubic-bezier(.2, .8, .2, 1), opacity .4s;
}
.vista[data-lado="izq"] { transform: translateX(-30%); opacity: 0; pointer-events: none; }
.vista[data-lado="der"] { transform: translateX(30%); opacity: 0; pointer-events: none; }
.vista h1 { font: 800 clamp(34px, 8vw, 72px)/1 ui-monospace, "JetBrains Mono", monospace; letter-spacing: -.04em; }
.vista h1 span { color: var(--verde); }
.vista p { color: var(--gris); max-width: 30ch; line-height: 1.5; }
/* La barra */
.barra {
position: relative;
width: min(420px, calc(100% - 32px));
height: 72px;
margin: 0 auto calc(18px + env(safe-area-inset-bottom));
border-radius: 36px;
background: var(--superficie);
border: 1px solid var(--borde);
box-shadow: 0 20px 50px rgba(0, 0, 0, .5);
}
/* El truco: las dos gotas se desenfocan y luego se «cortan» con contraste,
así cuando se acercan se funden como un líquido. */
.liquido { position: absolute; inset: 0; filter: url(#goo); pointer-events: none; overflow: hidden; border-radius: inherit; }
.gota {
position: absolute; top: 50%; left: 0;
width: 56px; height: 56px; margin: -28px 0 0 -28px;
border-radius: 50%;
background: var(--verde);
will-change: transform;
}
.gota.cola { width: 40px; height: 40px; margin: -20px 0 0 -20px; }
/* El cuello: una barra entre la gota y su cola que adelgaza al estirarse */
.cuello { position: absolute; top: 50%; left: 0; width: 1px; height: 30px; margin-top: -15px; background: var(--verde); border-radius: 15px; transform-origin: 0 50%; will-change: transform; }
.tabs { position: relative; display: grid; grid-template-columns: repeat(4, 1fr); height: 100%; }
.tab {
appearance: none; border: 0; background: none; font: inherit;
color: var(--gris);
display: flex; align-items: center; justify-content: center; gap: 8px;
cursor: pointer;
transition: color .3s;
}
.tab svg { width: 24px; height: 24px; flex: none; transition: transform .4s cubic-bezier(.3, 1.6, .5, 1); }
.tab span { display: none; font-weight: 600; font-size: 14px; }
.tab[aria-selected="true"] { color: var(--fondo); }
.tab[aria-selected="true"] svg { transform: scale(1.15) translateY(-1px); }
.tab:focus-visible { outline: 2px solid var(--verde); outline-offset: -8px; border-radius: 30px; }
.tab:hover:not([aria-selected="true"]) { color: var(--blanco); }
/* Escritorio: la barra sube arriba, enseña los textos y la gota se alarga en píldora */
@media (min-width: 768px) {
.app { flex-direction: column-reverse; }
.barra { width: 640px; margin: 28px auto 0; }
.tab span { display: inline; }
.gota { width: 132px; border-radius: 28px; margin-left: -66px; }
}
@media (prefers-reduced-motion: reduce) {
.vista, .tab svg { transition: none; }
}
const barra = document.getElementById('barra');
const gota = document.getElementById('gota');
const colaEl = document.getElementById('cola');
const cuello = document.getElementById('cuello');
const tabs = [...document.querySelectorAll('.tab')];
const vistas = [...document.querySelectorAll('.vista')];
const quieto = matchMedia('(prefers-reduced-motion: reduce)').matches;
let activa = 0;
let x = 0, v = 0; // gota principal: posición y velocidad
let xc = 0, vc = 0; // la cola: persigue a la gota con retraso
let objetivo = 0;
let animando = false;
const centro = i => {
const r = tabs[i].getBoundingClientRect();
return r.left - barra.getBoundingClientRect().left + r.width / 2;
};
function pintar() {
// Se estira en la dirección del movimiento y se aplasta en vertical, como una gota
const s = Math.min(Math.abs(v) / 28, .5);
gota.style.transform = `translateX(${x}px) scale(${1 + s}, ${1 - s * .55})`;
const d = Math.abs(x - xc);
colaEl.style.transform = `translateX(${xc}px) scale(${.6 + Math.min(d / 260, .3)})`;
// Cuanto más lejos está la cola, más fino el cuello (como un hilo de miel)
const grosor = Math.max(.22, 1 - d / 240);
cuello.style.transform = `translateX(${Math.min(x, xc)}px) scale(${d}, ${grosor})`;
}
function paso() {
v = (v + (objetivo - x) * .11) * .69; // muelle con rozamiento
x += v;
vc = (vc + (x - xc) * .07) * .76; // la cola persigue a la gota
xc += vc;
pintar();
const resta = Math.abs(v) + Math.abs(vc) + Math.abs(objetivo - x) + Math.abs(x - xc);
if (resta > .05) requestAnimationFrame(paso); else animando = false;
}
function ir(i, foco = false) {
if (i < 0 || i >= tabs.length) return;
const anterior = activa;
activa = i;
tabs.forEach((t, k) => {
t.setAttribute('aria-selected', k === i);
t.tabIndex = k === i ? 0 : -1;
});
vistas.forEach((vi, k) => {
if (k === i) vi.removeAttribute('data-lado');
else vi.dataset.lado = k < i ? 'izq' : 'der';
});
if (foco) tabs[i].focus();
objetivo = centro(i);
if (quieto) { x = xc = objetivo; v = vc = 0; pintar(); return; }
if (anterior !== i && !animando) { animando = true; requestAnimationFrame(paso); }
}
tabs.forEach((t, i) => t.addEventListener('click', () => ir(i)));
barra.addEventListener('keydown', e => {
if (e.key === 'ArrowRight') ir(Math.min(activa + 1, tabs.length - 1), true);
if (e.key === 'ArrowLeft') ir(Math.max(activa - 1, 0), true);
if (e.key === 'Home') ir(0, true);
if (e.key === 'End') ir(tabs.length - 1, true);
});
// Deslizar con el dedo (o el ratón) sobre la pantalla cambia de pestaña
const zona = document.getElementById('vistas');
let x0 = null, y0 = 0;
zona.addEventListener('pointerdown', e => { x0 = e.clientX; y0 = e.clientY; });
zona.addEventListener('pointerup', e => {
if (x0 === null) return;
const dx = e.clientX - x0, dy = e.clientY - y0;
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) ir(activa + (dx < 0 ? 1 : -1));
x0 = null;
});
function colocar() {
objetivo = x = xc = centro(activa);
v = vc = 0;
pintar();
}
addEventListener('resize', colocar);
colocar();