Efecto de encabezado fijo, navegación por anclas y activación de tabs mediante scroll en Vue.js

La implementación de un encabezado que se mantiene fijo (sticky header) combinado con navegación por anclas y sincronización automática de tabs según la posición del scroll es un patrón muy común en aplicaciones móviles H5 construidas con Vue.js. A continuación se presenta una solución completa que reescribe la lógica utilizando una estructura diferente.

Componente Vue

<template>
  <div class="exam-container">
    <div
      ref="stickyBar"
      class="exam-toolbar"
      :class="{ 'sticky-active': stickyEnabled }"
    >
      <div class="toolbar-top">
        <i class="icon-back" @click="goBack">&lt;</i>
        <span class="toolbar-title">Exploración médica</span>
        <input
          v-model="searchTerm"
          class="toolbar-search"
          placeholder="Buscar elemento"
        />
      </div>

      <nav class="tab-bar">
        <div
          v-for="(tab, idx) in tabList"
          :key="tab.id"
          class="tab-item"
          @click="jumpToSection(idx)"
        >
          <span :class="{ 'tab-selected': activeTab === idx }">
            {{ tab.label }}
          </span>
          <div v-if="activeTab === idx" class="tab-indicator"></div>
        </div>
      </nav>
    </div>

    <div class="section-wrapper">
      <div
        v-for="(block, idx) in sectionData"
        :key="block.id"
        :ref="el => sectionRefs[idx] = el"
        class="content-block"
      >
        <div class="block-header">
          <div class="header-accent"></div>
          <h3 class="header-text">{{ block.label }}</h3>
        &div>
        <div class="block-body">
          <div
            v-for="entry in block.entries"
            :key="entry.id"
            class="entry-card"
          >
            <p class="entry-label">{{ entry.title }}</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { fetchExamData } from "@/services/examApi";

export default {
  name: "StickyAnchorView",
  data() {
    return {
      searchTerm: "",
      tabList: [],
      sectionData: [],
      activeTab: 0,
      stickyEnabled: false,
      stickyThreshold: 0,
      sectionRefs: [],
      scrollHandler: null
    };
  },
  async created() {
    await this.loadSections();
  },
  mounted() {
    this.scrollHandler = this.handleScroll.bind(this);
    window.addEventListener("scroll", this.scrollHandler, { passive: true });
    this.$nextTick(() => {
      this.stickyThreshold = this.$refs.stickyBar.offsetTop;
    });
  },
  beforeDestroy() {
    window.removeEventListener("scroll", this.scrollHandler);
  },
  methods: {
    goBack() {
      this.$router.go(-1);
    },
    jumpToSection(idx) {
      this.activeTab = idx;
      const target = this.sectionRefs[idx];
      if (!target) return;
      const offset = target.offsetTop - 130;
      window.scrollTo({ top: offset, behavior: "smooth" });
    },
    handleScroll() {
      const scrollY =
        window.pageYOffset ||
        document.documentElement.scrollTop ||
        document.body.scrollTop;

      this.stickyEnabled = scrollY > this.stickyThreshold;

      const blocks = this.sectionRefs;
      for (let i = blocks.length - 1; i >= 0; i--) {
        const relativeOffset = blocks[i].offsetTop - blocks[0].offsetTop - 130;
        if (scrollY >= relativeOffset) {
          this.activeTab = i;
          break;
        }
      }
    },
    async loadSections() {
      try {
        const response = await fetchExamData();
        const info = response.data.data.items;
        this.tabList = info.map(item => ({
          id: item.id,
          label: item.name
        }));
        this.sectionData = info;
      } catch (error) {
        console.error("Error loading sections:", error);
      }
    }
  }
};
</script>

Estilos

.exam-container {
  width: 100%;
}

.exam-toolbar {
  width: 100%;
  background: #ffffff;
  border-bottom: 1px solid #f0f0f0;

  .toolbar-top {
    display: flex;
    align-items: center;
    padding-top: 30px;
    gap: 8px;

    .icon-back {
      color: #000;
      font-size: 22px;
      font-weight: bold;
      cursor: pointer;
    }

    .toolbar-title {
      font-size: 18px;
      font-weight: bold;
      color: #000;
    }

    .toolbar-search {
      flex: 1;
      max-width: 60%;
      border-radius: 21px;
      background: #f9f9f9;
      border: 1px solid #eee;
      padding: 6px 12px;
    }
  }

  .tab-bar {
    display: flex;
    flex-wrap: nowrap;
    overflow-x: auto;
    overflow-y: hidden;
    margin-top: 5px;

    .tab-item {
      margin-left: 20px;
      line-height: 40px;
      text-align: center;
      white-space: nowrap;
      font-size: 16px;
      cursor: pointer;

      &:last-child {
        margin-right: 20px;
      }

      span {
        text-decoration: none;
        color: #a8a8a8;
      }

      .tab-selected {
        color: #00a0e9;
      }

      .tab-indicator {
        width: 20px;
        height: 2px;
        background: #00a0e9;
        margin: 0 auto;
        border-radius: 20px;
      }
    }
  }
}

.section-wrapper {
  background: #f9f9f9;
  padding: 10px 0 30px;

  .content-block {
    width: 90%;
    margin: 10px auto;
    background: #fff;

    .block-header {
      display: flex;
      align-items: center;
      padding: 10px;

      .header-accent {
        height: 16px;
        width: 3px;
        border-radius: 10px;
        background: #00a0e9;
      }

      .header-text {
        font-weight: bold;
        color: #000;
        margin-left: 5px;
        font-size: 15px;
      }
    }

    .block-body {
      display: flex;
      flex-wrap: wrap;

      .entry-card {
        width: 30%;
        background: #f9f9f9;
        text-align: center;
        margin: 10px 5px;

        .entry-label {
          font-size: 14px;
          font-weight: 400;
          word-break: break-word;
        }
      }
    }
  }
}

.sticky-active {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 999;
}

Explicación de la lógica principal

El componente gestiona tres comportamientos interrelacionados:

  • Encabezado fijo (sticky): Durante el evento mounted, se captura el offsetTop del elemento de referencia. En cada evento de scroll, se compara la posición actual del scroll con ese umbral. Si lo supera, se aplica la clase sticky-active que convierte el encabezado en position: fixed.
  • Navegación por anclas: Al hacer clic en un tab, el método jumpToSection localiza la sección correspondiente utilizando referencias dinámicas (sectionRefs) en lugar de querySelector, lo que resulta más robusto. Se utiliza window.scrollTo con behavior: 'smooth' para una transición fluida.
  • Activación automática de tabs: El método handleScroll recorre las secciones en orden inverso. Cuando detecta que la posición del scroll supera el umbral relativo de una sección, actualiza activeTab y detiene el bucle.

Consideraciones importantes

El listener de scroll se registra con { passive: true } para mejorar el rendimiento en dispositivos móviles. Es obligatorio eliminar el listener en beforeDestroy para evitar fugas de memoria al navegar entre rutas. El desplazamiento aplica un offset fijo de 130 píxeles para compensar la altura del encabezado fijo y evitar que el contenido quede oculto detrás del mismo.

Etiquetas: vue.js CSS position sticky scroll event anchor navigation H5 mobile

Publicado el 7-22 05:25