Skip to content
Aprende Angular de forma rápida y efectiva  Ver curso

Como crear un elemento sticky en Angular

Seguro que has visto alguna vez esos headers que en un momento dado del scroll se quedan fijados a la vista. Debajo puedes ver un par de ejemplos de lo que digo, implementados en mi nueva web… ¿Nunca te has preguntado como se crea un componente con estas características?

Ejemplo de sticky header bar

Ejemplo de sticky social buttons

Creando un elemento sticky en Angular

Pues si te preguntabas como hacerlo, estas de suerte, voy a mostrarte como crear un elemento sticky, como el del ejemplo sticky social buttons, con Angular.

Además, voy a generalizar la lógica y extraerla a una directiva, para conseguir un comportamiento reutilizable.

Entendiendo el problema

Lo primero es entender el problema:

Imagina que tienes una página bastante larga (el artículo de un blog, por ejemplo) y hacia el final de la misma hay un elemento que es importante. Es tan importante, que quieres que se muestre de forma fija mientras el usuario no llegue a su posición original haciendo scroll. Lo llamaremos «elemento sticky«.

La siguiente imagen muestra las condiciones iniciales en el momento de la carga de la página.

Condiciones iniciales
sticky element initial conditions

En el momento que se carga la página, puedes tener (o no) un cierto scroll (S0), que sumado al offset desde el viewport al elemento sticky (VO0), da la distancia original del elemento sticky al inicio de la página.

En este caso todavía no has hecho suficiente scroll y por tanto el elemento sticky no está visible. Eso es justo lo que quieres cambiar.

¿No sería genial añadirle una clase .fixed a este elemento para añadirle el estilo position:fixed por CSS? El objetivo es que cuando el elemento sticky tenga esta clase, se muestre en la zona inferior de la vista, siempre con el mismo offset hasta el bottom.

Elemento fijado
sticky element on position fixed

Como ves, el offset desde el viewport al elemento sticky siempre será el mismo (VOf) mientras el elemento esté fijado.

Entonces… ¿cual es el umbral para decidir si el elemento debe tener la clase .fixed o no? Imagina una situación de scroll cualquiera…

sticky element scroll situation

Es fácil darse cuenta de que el punto crítico es cuando
Si + VOf = S0 + VO0

Para ser más exactos, la clase .fixed se debe añadir cuando
Si + VOf < S0 + VO0

¿Y cuando debe eliminarse la clase .fixed?

En este caso hay que fijarse en la situación contraria, cuando el elemento sticky ya no está fijado sino que se mueve al hacer scroll por que el usuario ha superado su posición. Sería el caso de la imagen siguiente:

sticky element no fixed, scroll situation

Fíjate que ahora he definido VOi, que corresponde al offset actual desde el viewport al elemento, cuyo valor cambia al hacer scroll por que el elemento sticky ya no está fijado.

La condición para eliminar la clase .fixed del elemento, será por tanto:
Si + VOi >= S0 + VO0

Directiva sticky, la solución

Ahora que está más claro lo que hay que hacer, es el momento de que te pongas manos a la obra. Vamos por partes:

1. Reusabilidad

Es posible que quieras utilizar esta lógica para distintos componentes en distintos momentos. Por eso, en lugar de encapsularla en un componente, es más útil vincularla una directiva atributo. La llamaré sticky-below-view.

De este modo, podrás añadir esta funcionalidad a cualquier componente o elemento de tu vista con solo añadirle este atributo:

<!-- some content -->
<p> Scroll down to see how the sticky element works </p>

<some-element sticky-below-view>
This is now a sticky element
</some-element>
<!-- some more content -->

2. Crear una directiva

Para crear esta directiva con la CLI de Angular, solo tienes que hacer: ng generate directive sticky-below-view.

Esto creará un elemento como el siguiente:

sticky-below-view.directive.ts

//sticky-below-view.directive.ts
import { Directive } from '@angular/core';

@Directive({
  selector: '[sticky-below-view]'
})
export class HighlightDirective {
  constructor() { }
}

Además, declarará la directiva en mi NgModule:

// app.module.ts
//...some imports...
import { StickyBelowViewDirective } from './sticky-below-view.directive';

@NgModule({
  imports:      [ BrowserModule, FormsModule ],
  declarations: [ AppComponent, StickyBelowViewDirective ],
  bootstrap:    [ AppComponent ],
})
export class AppModule { }

3. Obteniendo el offset inicial

Lo primero que hay que hacer es obtener el offset inicial del elemento, es decir, la suma S0 + VO0.

Para eso creo el siguiente método en la directiva:

//sticky-below-view.directive.ts
//...some stuff...
export class HighlightDirective {

  private initialOffsetFromTop =  0;

  constructor(private element: ElementRef) { }

  private getInitialOffset(){
    let initialViewportOffset = this.element.nativeElement.getBoundingClientRect().top;
    let currentScroll = window.scrollY;
    this.initialOffsetFromTop = initialViewportOffset + currentScroll;  
 }
}
  • Obtengo S0 a partir de window.scrollY.
  • Obtengo VO0 llamando al método getBoundingClientRect().top del elemento que contiene la directiva (lo obtengo inyectando ElementRef en el constructor).
  • Y finalmente guardo la suma S0 + VO0 en la propiedad initialOffsetFromTop.

4. Obteniendo el offset en posición fija

También necesito guardarme otro offset, que es el de cuando el elemento está en posición fija, es decir, cuando se le aplica la clase .fixed.

En el ejemplo que te pongo, mi estilo .fixed para este elemento es así de sencillo:

.fixed{
  position:  fixed;
  bottom:  50px;
}

Esta posición no la conozco de antemano (podría tener distintos valores de bottom para distintos elementos), así que la mejor forma de conocer este valor, es añadiendo momentáneamente la clase al elemento.

Para eso añado un nuevo método getFixedViewportOffset a mi directiva:

//sticky-below-view.directive.ts
//...some stuff...
export class HighlightDirective {

  private initialOffsetFromTop =  0;
  private fixedViewportOffset = 0;

  constructor(
      private element: ElementRef,
      private renderer:Renderer2
      ) { }

  private getInitialOffset(){/*...*/}

  private getFixedViewportOffset(){
      //set the fixed class
      this.renderer.addClass(this.element.nativeElement, 'fixed');
      //save the view offset in fixed position
      this.fixedViewportOffset = this.element.nativeElement.getBoundingClientRect().top;
      //remove again the fixed class
      this.renderer.removeClass(this.element.nativeElement, 'fixed');
  }
}  
  • He utilizado el servicio Renderer2 de Angular para añadir la clase .fixed al elemento.
  • Esto ha modificado su posición debido al CSS de dicha clase, y he aprovechado para calcular el nuevo offset con respecto al viewport (lo guardo en fixedViewportOffset).
  • Finalmente, quito la clase .fixed del elemento.

5. Escuchando al evento de scroll

Si echas un vistazo a mi blog, verás que en artículos anteriores he explicado en detalle como escuchar el evento scroll.

Aquí te presento la forma más simple de hacerlo, pero te recomiendo darle un vistazo a este servicio de scroll que publiqué hace unos días. De hecho, el código de StackBlitz que encontrarás con la demo al final de artículo utiliza una variación de este servicio de scroll para dejar más limpio el código.

Volviendo al ejemplo, lo que necesitas ahora es escuchar el evento de scroll, y añadir la clase .fixed o eliminarla, en función de la situación. Para eso añado el método handleScroll decorado con un HostListener del evento scroll:

//sticky-below-view.directive.ts

//...some stuff...
enum StickyState {
  fixed =  "fixed",
  noFixed =  "no-fixed"
}

//...some more stuff...
export class HighlightDirective {

  private fixedState = StickyState.noFixed;
  private initialOffsetFromTop =  0;
  private fixedViewportOffset = 0;

  constructor(
      private element: ElementRef,
      private renderer:Renderer2
      ) { 
        this.getInitialOffset();
        this.getFixedViewportOffset();
      }

  private getInitialOffset(){/*...*/}

  private getFixedViewportOffset(){/*...*/}

  @HostListener("window:scroll", ['$event'])
  private handleScroll($event:Event){
    let currentScroll = $event.srcElement.children[0].scrollTop;

    //if not fixed
    //and we have not yet scrolled until the original position of the element
    //add the fixed class
    if(this.fixedState == StickyState.noFixed &&
      currentScroll + this.fixedViewportOffset < this.initialOffsetFromTop){
        this.fixedState = StickyState.fixed;
        this.renderer.addClass(this.element.nativeElement, 'fixed');
    }
    //if fixed
    else if(this.fixedState == StickyState.fixed){
      let currentOffsetFromTop = currentScroll + this.element.nativeElement.getBoundingClientRect().top;
      //and the current offset from top is greater or equal than the original
     //remove the fixed class
      if (currentOffsetFromTop >= this.initialOffsetFromTop){
        this.fixedState = StickyState.noFixed;
        this.renderer.removeClass(this.element.nativeElement, 'fixed');
      }
    }
  }  
}  
  • He creado la propiedad fixedStage a la que le asigno valores del enum StickyState, para ver de forma clara en qué estado se encuentra el elemento.
  • He inicializado el offset fijo y el inicial en el constructor, llamando a los métodos que he creado al principio.
  • En el método handleScroll recojo el scroll actual y aplico las formulas que he definido al principio del artículo, para ver cuando añadir la clase .fixed y cuando eliminarla del elemento.

Resultado final

El código anterior te permite añadir la directiva atributo sticky-below-view a cualquier elemento, modificando su comportamiento cuando se hace scroll en función de cómo definas la clase CSS .fixed.

Dale un vistazo al siguiente StackBlitz para ver el resultado:

Conclusiones

Mi objetivo de hoy era mostrarte un caso práctico de la utilidad de las directivas en Angular, en este caso con una aplicación vistosa y llamativa que (espero) haya captado tu atención.

Si te ha parecido interesante, la barra de social sharing de está web sigue ese mismo principio, así que te animo a que la uses 😉

¿Te ha gustado este artículo? No te cortes, déjame un comentario y ayúdame a compartirlo 😉

Published inAngular

4 Comments

  1. Jesús Jesús

    Gracias por tu tiempo! te sigo desde udemy y eres un máquina!

  2. Hola Enrique!

    He intentado seguir el tutorial con angular universal.

    No entiendo por que this.fixedViewportOffset = this.element.nativeElement.getBoundingClientRect().top; siempre da 0; incluso teniendo el elemento en la mitad de la pantalla…

    He intentado meter la inicialización de la directiva (this.initStickyElement() ) dentro del ciclo de vida AfterViewInit y lo mismo… con document referenciado una clase en particular pasa lo mismo..

    Sabes por que puede pasar? se te ocurre alguna forma de poder remediar esto en angular universal ?

    Cómo siempre! enorme el contenido que ofreces! y tus cursos!!

  3. German German

    Hola! quedo deprecado el srcElement.
    $event.srcElement.children[0].scrollTop;

    encontre que ahora se usa target, pero ni idea lo de children y el scrolltop.

    Podras actualizar el post?

    Gracias!

    german Kolberg

    • Snow Crash Snow Crash

      Quizá es tarde para responderte, pero dejo una solución por aquí por si a alguien le sirve. La idea es cambiar

      $event.srcElement.children[0].scrollTop;

      por

      ($event.target as Element).scrollTop;

      Un saludo

Deja un comentario