ATOM
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.

Preview

Anatomía CSS

<div class="wf-design-mode">

</div>
ClasePropósito
wf-design-moderoot

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#ffffffcard
allfg#525252muted.foreground
allbordernone(unparsed)

Animaciones

PropiedadDuraciónEasing
colorvar(--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;
  }
}

On this page

Detalles

Publicado14 de mayo de 2026
Categoriasurfaces
Lectura...
Visitas...
Ayuda?Slack

Componente

BouncyTabs

Source

components-react / cssDisponible via MCP: atom_uikit_source("bouncy-tabs")