Components (registry)Surfaces
BouncyTabs
Pestañas con indicador elastico y card que se comprime al conmutar. El indicador viaja entre botones estirandose entre anchos distintos, se pasa unos pixeles y vuelve.
Anatomía CSS
<div class="wf-design-mode"> </div>
| Clase | Propósito |
|---|---|
wf-design-mode | root |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #ffffff | card |
| all | fg | #525252 | muted.foreground |
| all | border | none | (unparsed) |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
color | var(--duration-300) | var(--easing-out) |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Bouncy Tabs
Pestañas con indicador elastico: la pastilla viaja entre botones estirandose
entre anchos distintos, se pasa unos pixeles y vuelve. Al conmutar, la card
se comprime hacia el sentido del viaje y se re-infla con muelle.
Estructura: .bouncy-tabs > .bouncy-tabs__nav > __ghost + __indicator + __button xN
> .bouncy-tabs__card > .bouncy-tabs__panels
> .bouncy-tabs__panel xN
El indicador y el ghost van DEBAJO de los botones en orden de apilado: el
texto del boton cambia de color por [data-active], no por estar encima.
TEMA: `data-theme="dark"` en CUALQUIER ancestro — incluido el propio root del
componente — basta. tokens.css declara `[data-theme=dark]` como selector
generico, no `:root[data-theme=dark]`, asi que reasigna los tokens solo dentro
de ese subarbol. Por eso aqui no hay ni una regla de tema: anadir un
modificador propio seria duplicar un contrato que el DS ya publica, y quedaria
desincronizado en cuanto cambie un token.
El componente NO pinta --background a proposito: en dark queda como una isla
oscura sobre el fondo de la pagina. Si alguna vez se quiere la banda entera
oscura, eso es responsabilidad del layout que lo envuelve, no de la pintura.
NOTA WEBFLOW: dentro del Designer no corre JS, asi que los paneles absolutos
nunca reciben altura y la card se colapsa en el canvas. El host lo resuelve
con `.wf-design-mode [data-bouncy-tabs-panel][data-active] { position: relative }`
en su propio codigo — no aqui: esa clase es del anfitrion y este paquete no
debe conocerla.
------------------------------------------------------------------------- */
.bouncy-tabs {
display: flex;
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif;
flex-direction: column;
align-items: center;
gap: 40px;
width: 100%;
}
/* ---- Nav ---- */
.bouncy-tabs__nav {
position: relative;
display: inline-flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
padding: 4px;
border-radius: 9999px;
background-color: #f5f5f5;
}
.bouncy-tabs__ghost {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 2em;
opacity: 0;
visibility: hidden;
pointer-events: none;
border-radius: 9999px;
background-color: color-mix(in srgb, #0a0a0a 8%, transparent);
}
.bouncy-tabs__indicator {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 3em;
pointer-events: none;
border-radius: 9999px;
background-color: #0a0a0a;
}
.bouncy-tabs__button {
position: relative;
z-index: 3;
appearance: none;
border: none;
border-radius: 9999px;
padding: 12px 20px;
background-color: transparent;
color: #525252;
font-size: 16px;
font-weight: 500;
/* line-height 1 es la convencion del DS para componentes interactivos: es lo
que hace que la altura del boton la fije el padding y no la fuente. */
line-height: 1;
letter-spacing: -0.02em;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: color 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-bouncy-tabs-button][data-active] {
color: #fafafa;
}
.bouncy-tabs__button:focus-visible {
outline: 2px solid #0a0a0a;
outline-offset: 1.5px;
}
/* ---- Card ---- */
.bouncy-tabs__card {
width: 100%;
padding: 40px;
border-radius: 24px;
background-color: #ffffff;
/* La superficie va con SU foreground, no con el heredado. Sin esto, cualquier
texto que no declare color propio (un heading, un <strong>) hereda el de la
pagina: con la card en dark sobre una pagina light sale casi negro sobre
#171717. El parrafo y los botones se salvaban solo porque declaran color. */
color: #0a0a0a;
/* El recorte es del borde redondeado de la card: el panel entrante se desliza
y debe cortarse ahi, no en el viewport. */
overflow: hidden;
}
.bouncy-tabs__panels {
position: relative;
width: 100%;
}
/* Los paneles se apilan en la misma caja para poder cruzarse; la altura la
anima el behavior sobre __panels. */
.bouncy-tabs__panel {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 32px;
}
[data-bouncy-tabs-panel]:not([data-active]) {
opacity: 0;
visibility: hidden;
}
.bouncy-tabs__panel-text {
display: flex;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 16px;
}
.bouncy-tabs__panel-visual {
width: 45%;
flex: none;
/* La proporcion la fija el hueco, no el archivo. Sin esto cada video impone su
alto natural al panel y el salto de altura entre pestanas es mayor de lo que
el contenido justifica; ademas object-fit no tiene contra que recortar. */
aspect-ratio: 16 / 10;
border-radius: 12px;
overflow: hidden;
}
.bouncy-tabs__panel-visual > * {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.bouncy-tabs__heading {
margin: 0;
font-size: 25px;
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.02em;
text-wrap: balance;
}
.bouncy-tabs__para {
margin: 0;
font-size: 16px;
font-weight: 400;
line-height: 1.5;
color: #525252;
text-wrap: pretty;
}
@media (max-width: 767px) {
.bouncy-tabs__nav {
border-radius: 16px;
}
.bouncy-tabs__card {
padding: 24px;
}
.bouncy-tabs__panel {
flex-direction: column;
align-items: stretch;
}
.bouncy-tabs__panel-visual {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.bouncy-tabs__button {
transition-duration: 0ms;
}
}
Codigo fuente
styles/components/bouncy-tabs.css
/* ------------------------------------------------------------------------- Bouncy Tabs Pestañas con indicador elastico: la pastilla viaja entre botones estirandose entre anchos distintos, se pasa unos pixeles y vuelve. Al conmutar, la card se comprime hacia el sentido del viaje y se re-infla con muelle. Estructura: .bouncy-tabs > .bouncy-tabs__nav > __ghost + __indicator + __button xN > .bouncy-tabs__card > .bouncy-tabs__panels > .bouncy-tabs__panel xN El indicador y el ghost van DEBAJO de los botones en orden de apilado: el texto del boton cambia de color por [data-active], no por estar encima. TEMA: `data-theme="dark"` en CUALQUIER ancestro — incluido el propio root del componente — basta. tokens.css declara `[data-theme=dark]` como selector generico, no `:root[data-theme=dark]`, asi que reasigna los tokens solo dentro de ese subarbol. Por eso aqui no hay ni una regla de tema: anadir un modificador propio seria duplicar un contrato que el DS ya publica, y quedaria desincronizado en cuanto cambie un token. El componente NO pinta --background a proposito: en dark queda como una isla oscura sobre el fondo de la pagina. Si alguna vez se quiere la banda entera oscura, eso es responsabilidad del layout que lo envuelve, no de la pintura. NOTA WEBFLOW: dentro del Designer no corre JS, asi que los paneles absolutos nunca reciben altura y la card se colapsa en el canvas. El host lo resuelve con `.wf-design-mode [data-bouncy-tabs-panel][data-active] { position: relative }` en su propio codigo — no aqui: esa clase es del anfitrion y este paquete no debe conocerla. ------------------------------------------------------------------------- */ .bouncy-tabs { display: flex; font-family: var(--font-family-sans); flex-direction: column; align-items: center; gap: var(--spacing-10); width: 100%; } /* ---- Nav ---- */ .bouncy-tabs__nav { position: relative; display: inline-flex; flex-wrap: wrap; justify-content: center; align-items: flex-start; padding: var(--spacing-1); border-radius: var(--radius-full); background-color: var(--muted); } .bouncy-tabs__ghost { position: absolute; top: 0; left: 0; z-index: 1; width: 2em; opacity: 0; visibility: hidden; pointer-events: none; border-radius: var(--radius-full); background-color: color-mix(in srgb, var(--foreground) 8%, transparent); } .bouncy-tabs__indicator { position: absolute; top: 0; left: 0; z-index: 2; width: 3em; pointer-events: none; border-radius: var(--radius-full); background-color: var(--primary); } .bouncy-tabs__button { position: relative; z-index: 3; appearance: none; border: none; border-radius: var(--radius-full); padding: var(--spacing-3) var(--spacing-5); background-color: transparent; color: var(--muted-foreground); font-size: var(--font-size-base); font-weight: var(--font-weight-medium); /* line-height 1 es la convencion del DS para componentes interactivos: es lo que hace que la altura del boton la fije el padding y no la fuente. */ line-height: 1; letter-spacing: var(--letter-spacing-tight); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-300) var(--easing-out); } [data-bouncy-tabs-button][data-active] { color: var(--primary-foreground); } .bouncy-tabs__button:focus-visible { outline: var(--ring-width) solid var(--ring); outline-offset: var(--ring-offset); } /* ---- Card ---- */ .bouncy-tabs__card { width: 100%; padding: var(--spacing-10); border-radius: var(--radius-2xl); background-color: var(--card); /* La superficie va con SU foreground, no con el heredado. Sin esto, cualquier texto que no declare color propio (un heading, un <strong>) hereda el de la pagina: con la card en dark sobre una pagina light sale casi negro sobre #171717. El parrafo y los botones se salvaban solo porque declaran color. */ color: var(--card-foreground); /* El recorte es del borde redondeado de la card: el panel entrante se desliza y debe cortarse ahi, no en el viewport. */ overflow: hidden; } .bouncy-tabs__panels { position: relative; width: 100%; } /* Los paneles se apilan en la misma caja para poder cruzarse; la altura la anima el behavior sobre __panels. */ .bouncy-tabs__panel { position: absolute; top: 0; left: 0; right: 0; display: flex; align-items: center; gap: var(--spacing-8); } [data-bouncy-tabs-panel]:not([data-active]) { opacity: 0; visibility: hidden; } .bouncy-tabs__panel-text { display: flex; flex: 1; flex-direction: column; align-items: flex-start; justify-content: center; gap: var(--spacing-4); } .bouncy-tabs__panel-visual { width: 45%; flex: none; /* La proporcion la fija el hueco, no el archivo. Sin esto cada video impone su alto natural al panel y el salto de altura entre pestanas es mayor de lo que el contenido justifica; ademas object-fit no tiene contra que recortar. */ aspect-ratio: 16 / 10; border-radius: var(--radius-lg); overflow: hidden; } .bouncy-tabs__panel-visual > * { display: block; width: 100%; height: 100%; object-fit: cover; } .bouncy-tabs__heading { margin: 0; font-size: var(--font-size-xl); font-weight: var(--font-weight-semibold); line-height: var(--line-height-xl); letter-spacing: var(--letter-spacing-tight); text-wrap: balance; } .bouncy-tabs__para { margin: 0; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); line-height: var(--line-height-base); color: var(--muted-foreground); text-wrap: pretty; } @media (max-width: 767px) { .bouncy-tabs__nav { border-radius: var(--radius-xl); } .bouncy-tabs__card { padding: var(--spacing-6); } .bouncy-tabs__panel { flex-direction: column; align-items: stretch; } .bouncy-tabs__panel-visual { width: 100%; } } @media (prefers-reduced-motion: reduce) { .bouncy-tabs__button { transition-duration: 0ms; } }