Criando um componente stories - Angular
O componente Stories é um recurso popular, utilizado por diversas redes sociais. Neste texto é apresentado como podemos construir um componente stories utilizando um pouco de CSS, HTML e Angular.

O componente stories é amplamente utilizado por diversos sites e aplicativos. Na minha opinião ele tem algumas semelhanças com o carrossel, que inclusive utilizei bastante no passado em conjunto com a lib jQuery. Neste texto gostaria de compartilhar como podemos construir um componente stories simples, utilizando Angular e um pouco de HTML e CSS. Ao longo do texto também vou abordar alguns assuntos pontuais que podem ser úteis no cotidiano do desenvolvedor.
Estruturando o projeto
Para estruturar o projeto utilizei as seguintes versões:
$ ng version
Angular CLI: 15.2.4
Node: 18.14.2
Package Manager: npm 9.5.0
OS: darwin arm64
Criando o projeto para desenvolver o componente stories:
# Cria o diretório
$ mkdir consolelog-stories
# Acessa o diretório recém criado
$ cd consolelog-stories/
# Cria o projeto Angular
#
# Adicionei o skip-tests e skip-git porque
# é um ambiente de estudo, então não é necessário
# criar testes unitários nem inicializar o
# repositório git
$ ng new projeto-stories --skip-tests --skip-git
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS
$ cd projeto-stories
# Executa o projeto:
$ npm run start
Executando o projeto na porta padrão (4200):

Agora que o projeto está criado, vamos partir para os arquivos que irão fazer parte do componente stories.
Estruturando o componente
Utilizei o CLI do Angular para criar os arquivos:
$ ng generate module stories
CREATE src/app/stories/stories.module.ts (193 bytes)
$ ng generate component stories --module stories --skip-tests
CREATE src/app/stories/stories.component.css (0 bytes)
CREATE src/app/stories/stories.component.html (22 bytes)
CREATE src/app/stories/stories.component.ts (206 bytes)
UPDATE src/app/stories/stories.module.ts (273 bytes)
$ ng generate class stories/StoriesItem --skip-tests --type model
CREATE src/app/stories/stories-item.model.ts (29 bytes)
Observação: repare que na criação da classe StoriesItem
o valor do parâmetro --type
é adicionado no final do nome do arquivo: nome-do-arquivo.<valor do type>.ts
.
Configurando o prettier (opcional)
Escrevi "opcional" neste tópico porque o procedimento descrito não é obrigatório para o componente que será construído, mas vale investir alguns minutos caso você nunca tenha lido ou usado a lib prettier
.
Esta lib, prettier
, ajuda a padronizar a formatação do código, é um ótimo recurso para criar um padrão consistente de formatação. Para iniciar sua configuração basta adicioná-la ao projeto utilizando o npm
:
npm i prettier --save-dev
O --save-dev
instala pacotes que serão usados ao longo de desenvolvimento, mas que não são usados para executar o projeto.
The --save-dev option allows you to save packages under the devDependencies object in your package.json file. Any packages listed under the devDependencies will not be installed when you are in the production environment.
https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwjmt9mNq6_-AhW1r5UCHUVgCC0QFnoECAsQAw&url=https%3A%2F%2Fsebhastian.com%2Fnpm-save-dev%2F&usg=AOvVaw1SaVueiuPop8E7eAGivIlJ
Após a instalação criei o arquivo .prettierrc
na raiz do projeto (no mesmo nível do arquivo package.json) para indicar alguns padrões de formatação como quantidade máxima de caracteres em uma linha, tamanho da tabulação, entre outros.
{
"bracketSameLine": false,
"bracketSpacing": true,
"printWidth": 60,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}
Para saber mais detalhes sobre as opções de configuração consulte este link.
Com isto já podemos utilizar o prettier para formatar um código:
- abra o arquivo
stories.module.ts
que foi gerado pelo CLI do Angular - pressione
ctrl + shift + p
no Windows oucommand + shift + P
no MacOS para abrir as opções no Visual Studio Code. - escolha a opção
Format Document With...
- selecione prettier

Antes da formatação:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoriesComponent } from './stories.component';
@NgModule({
declarations: [
StoriesComponent
],
imports: [
CommonModule
]
})
export class StoriesModule { }
Após a formatação:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoriesComponent } from './stories.component';
@NgModule({
declarations: [StoriesComponent],
imports: [CommonModule],
})
export class StoriesModule {}
Aqui vale uma dica, podemos criar um script dentro do package.json para formatar todos os arquivos desejados. Criei o script format
para executar esta tarefa:
{
"ng": "ng",
"format": "npx prettier --write \"src/**/*.ts\" \"src/**/*.css\" \"src/**/*.html\"",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
}
Executando o script:
$ npm run format
> [email protected] format
> npx prettier --write "src/**/*.ts" "src/**/*.css" "src/**/*.html"
src/app/app.component.ts 90ms
src/app/app.module.ts 4ms
src/app/stories/stories.component.ts 3ms
src/app/stories/stories.module.ts 4ms
src/main.ts 4ms
src/app/app.component.css 11ms
src/app/stories/stories.component.css 1ms
src/styles.css 4ms
src/app/app.component.html 113ms
src/app/stories/stories.component.html 7ms
src/index.html 3ms
Agora todos os arquivo estão formatados 😬
O legal do uso do prettier
é que conseguimos criar uma consistência na formatação do código, assim o projeto inteiro terá a mesma padronização em sua formatação.
Implementação
Voltando ao componente stories, podemos perceber que o stories.component.ts
já foi declarado no stories.module.ts
, isto porque indicamos o módulo na criação do componente no comando do CLI. Então a única coisa necessária é adicionar o componente no exports
para expor o StoriesComponent
para quem importar o StoriesModule
:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoriesComponent } from './stories.component';
@NgModule({
declarations: [StoriesComponent],
exports: [StoriesComponent],
imports: [CommonModule],
})
export class StoriesModule {}
O arquivo stories-item.model.ts
terá um modelo de dados para representar o conteúdo que o componente stories irá mostrar. Para isto vamos considerar que cada slide (imagem) que irá aparecer no componente será representada pelo seguinte conteúdo:
export interface StoriesItem {
descricao: string;
descricaoCorHex: string;
status: StoriesItemStatus;
urlImagem: string;
}
export enum StoriesItemStatus {
'ativo' = 'ativo',
'aguardando' = 'aguardando',
'finalizado' = 'finalizado',
}
descricao
: texto que irá aparecer no meio da imagemdescricaoCorHex
: cor em hexadecimal do texto (acima)status
: será utilizado no HTML e também na lógica para controlar a transição das imagensurlImagem
: endereço da imagem que será exibida
O mecanismo para gerenciar o conteúdo que é apresentado na tela ficará no stories.component.ts
. Acrescentei alguns comentários para facilitar o entendimento:
import {
Component,
Input,
OnChanges,
OnDestroy,
} from '@angular/core';
import { Subscription, timer } from 'rxjs';
import {
StoriesItem,
StoriesItemStatus,
} from './stories-item.model';
@Component({
selector: 'app-stories',
templateUrl: 'stories.component.html',
styleUrls: ['stories.component.css'],
})
export class StoriesComponent
implements OnChanges, OnDestroy
{
private controleIntervalo!: Subscription;
@Input()
intervaloEntreSlidesEmSegundos: number = 3;
@Input()
itens: StoriesItem[] = [];
/**
* Retorna o index do array "itens" que
* está com o status ativo
*/
get slideAtivoIndex(): number {
return this.itens.findIndex(
(a) => a.status === StoriesItemStatus.ativo
);
}
/**
* Retorna o item do array "itens" que
* está com o status ativo
*/
get slideAtivo(): StoriesItem {
return this.itens[this.slideAtivoIndex];
}
// Quando o componente receber um valor
// em algums @Input, este método será
// executado
ngOnChanges() {
this.reset();
}
ngOnDestroy() {
this.pararTimer();
}
reset() {
this.pararTimer();
this.itens.forEach(
(a) => (a.status = StoriesItemStatus.aguardando)
);
this.itens[0].status = StoriesItemStatus.ativo;
this.iniciarTimer();
}
iniciarTimer(): void {
this.controleIntervalo = timer(
this.intervaloEntreSlidesEmSegundos * 1e3
).subscribe(() => {
this.avancarSlide();
});
}
avancarSlide(): void {
this.irParaSlide(this.slideAtivoIndex + 1);
}
pararTimer(): void {
this.controleIntervalo?.unsubscribe();
}
irParaSlide(index: number) {
this.pararTimer();
// Faz um ajuste no valor do index:
// index = 1
// │
// ▼
// ┌───┐ ┌───┐ ┌───┐
// │ 0 │ │ 1 │ │ 2 │
// └───┘ └───┘ └───┘
// index = 3
// │ ajusta o valor
// ┌─────┘ para 0
// ▼
// ┌───┐ ┌───┐ ┌───┐
// │ 0 │ │ 1 │ │ 2 │
// └───┘ └───┘ └───┘
//
// index = -1
// │ ajusta o valor
// └─────┐para 2
// ▼
// ┌───┐ ┌───┐ ┌───┐
// │ 0 │ │ 1 │ │ 2 │
// └───┘ └───┘ └───┘
//
// Obs.: Para desenhar assim:
// https://asciiflow.com/#/
//
if (index >= this.itens.length) {
index = 0;
} else if (index < 0) {
index = this.itens.length - 1;
}
// O trecho abaixo ajusta os status:
// Itens cujo o index seja menor que o valor da
// variável "index" são marcados como finalizados
for (let i = 0; i < index; i++) {
this.itens[i].status = StoriesItemStatus.finalizado;
}
this.itens[index].status = StoriesItemStatus.ativo;
// Itens cujo o index seja maior que o valor da
// variável "index" são marcados como aguardando
for (let i = index + 1; i < this.itens.length; i++) {
this.itens[i].status = StoriesItemStatus.aguardando;
}
this.iniciarTimer();
}
}
Para testar a lógica vamos adicionar o seguinte conteúdo no template:
<div>
<pre><code>{{ slideAtivo | json }}</code></pre>
</div>
<div>
<hr />
<pre><code>{{ itens | json }}</code></pre>
</div>
Também não podemos esquecer de importar o StoriesModule
no AppModule
e ajustar o template do AppComponent
:
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { StoriesModule } from './stories/stories.module';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, StoriesModule],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
import { Component } from '@angular/core';
import {
StoriesItem,
StoriesItemStatus,
} from './stories/stories-item.model';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
itens: StoriesItem[] = [
{
descricao:
'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Incidunt, modi!',
descricaoCorHex: '#fff',
status: StoriesItemStatus.aguardando,
urlImagem:
'https://wallpapers.com/images/hd/splashing-water-gradient-background-mobile-v5hlk4ta55rn5w3x.jpg',
},
{
descricao:
'Lorem ipsum dolor sit amet consectetur adipisicing elit. Saepe doloribus aliquam repudiandae officiis et itaque.',
descricaoCorHex: '#fff',
status: StoriesItemStatus.aguardando,
urlImagem:
'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQQQjw1nyTi1qvWVyCufBIEuFx8B6Zn9TOLVw&usqp=CAU',
},
{
descricao: 'Lorem ipsum dolor sit amet.',
descricaoCorHex: '#fff',
status: StoriesItemStatus.aguardando,
urlImagem:
'https://marketplace.canva.com/EAFJd1mhO-c/1/0/900w/canva-colorful-watercolor-painting-phone-wallpaper-qq02VzvX2Nc.jpg',
},
];
}
<app-stories [itens]="itens"></app-stories>
Executando o projeto podemos observar a transição do slideAtivo
e do valor do campo status
nos itens
:

Até este ponto sabemos que a lógica funciona bem, então podemos focar no HTML e CSS.
Criando o HTML e CSS
Para não deixar o texto tão extenso colei abaixo o código completo do HTML e CSS com vários comentários para facilitar o entendimento:
<div class="container">
<!-- ###################################### -->
<!-- BARRAS DE PROGRESSO -->
<!-- ###################################### -->
<div class="barra-progresso-container">
<!--
Faz um loop para criar uma barra de
progresso para cada imagem.
Este <div> será o background da
barra de progresso.
-->
<div
*ngFor="let item of itens; let i = index"
(click)="irParaSlide(i)"
class="barra-progresso"
>
<!--
Indicador de percentual que avança
conforme o tempo.
O tempo é indicado no animation-duration,
ou seja, o valor '3s' indica que a barra
irá de 0% a 100% de largura em 3 segundos.
O 'data-status' indica a situação da
barra. Utilizamos este atributo como
seletor no CSS.
-->
<div
class="barra-progresso-percentual"
[attr.data-status]="item.status"
[style.animation-duration]="
intervaloEntreSlidesEmSegundos + 's'
"
></div>
</div>
</div>
<!-- ###################################### -->
<!-- IMAGEM E DESCRIÇÃO -->
<!-- ###################################### -->
<div
class="imagem"
[style.background-image]="
'url(' + slideAtivo.urlImagem + ')'
"
>
<!-- Descrição -->
<div [style.color]="slideAtivo.descricaoCorHex">
{{ slideAtivo.descricao }}
</div>
</div>
<!-- ###################################### -->
<!-- PREFETCH DAS IMAGENS -->
<!--
Faz o pre fetch das imagens. Assim não
haverá um delay durante a primeira exibição
de cada imagem.
Comente este trecho com a opção Disable
Cache marcada no seu DevTools para observar
este comportamento.
-->
<!-- ###################################### -->
<img
*ngFor="let item of itens"
[src]="item.urlImagem"
style="width: 1px; height: 1px; opacity: 0"
/>
</div>
/* ***************************************************** */
/* VARIÁVEIS UTILIZADAS AO LONGO DAS CLASSES */
/* ***************************************************** */
::ng-deep :root {
--altura-imagem: 80vh;
--barra-progress-gap: 16px;
--container-background: radial-gradient(#666, #000);
--descricao-font: 32px Georgia;
--largura-imagem: 60vw;
--largura-maxima-imagem: 480px;
}
.container {
background-repeat: no-repeat;
background-size: cover;
background: var(--container-background);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
}
/* ****************** */
/* IMAGEM E TEXTO */
/* ****************** */
.container .imagem {
align-items: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 8px;
box-shadow: 0px 0px 10px #eee;
display: flex;
height: var(--altura-imagem);
margin: 0 auto;
max-width: var(--largura-maxima-imagem);
transition: all 0.5s ease-in-out;
width: var(--largura-imagem);
}
.container .imagem div {
background: rgba(0, 0, 0, 0.5);
flex-grow: 1;
font: var(--descricao-font);
padding: 12px;
text-align: center;
}
/* ****************** */
/* BARRA DE PROGRESSO */
/* ****************** */
.barra-progresso-container {
display: flex;
gap: var(--barra-progress-gap);
margin: 12px auto;
max-width: var(--largura-maxima-imagem);
width: var(--largura-imagem);
}
.barra-progresso,
.barra-progresso-percentual {
border-radius: 8px;
flex-grow: 1;
height: 4px;
}
.barra-progresso {
background: #fff;
cursor: pointer;
width: 100%;
}
.barra-progresso-percentual {
background: #aaa;
width: 0;
}
.barra-progresso-percentual[data-status='ativo'] {
animation-fill-mode: forwards;
animation-timing-function: linear;
animation: animacao-progresso;
}
.barra-progresso-percentual[data-status='finalizado'] {
width: 100%;
}
@keyframes animacao-progresso {
0% {
width: 0;
}
100% {
width: 100%;
}
}
Resultado:

Aplicando eventos de teclado
Até este ponto o componente já troca as imagens a cada intervalo de tempo definido no componente. A ideia deste tópico é explorar um mecanismo para avançarmos ou retrocedermos a sequência de imagens através do teclado, das teclas direcionais esquerda e direita.
Olha o código podemos observar que já existe um método chamado avancarSlide
que mostra a próxima imagem da sequência. Então para retroceder podemos criar um novo método conforme abaixo:
// método já existente
avancarSlide(): void {
this.irParaSlide(this.slideAtivoIndex + 1);
}
// novo método
retrocederSlide(): void {
this.irParaSlide(this.slideAtivoIndex - 1);
}
Para ativar um destes métodos quando as setas direita e esquerda do teclado forem pressionadas, podemos utilizar o decorator HostListener
passando o parâmetro document:keydown.<tecla>
:
@HostListener('document:keydown.ArrowRight')
avancarSlide(): void {
this.irParaSlide(this.slideAtivoIndex + 1);
}
@HostListener('document:keydown.ArrowLeft')
retrocederSlide(): void {
this.irParaSlide(this.slideAtivoIndex - 1);
}
Veja no resultado abaixo que ao pressionar as setas do teclado (esquerda e direita) o componente avança ou retrocede a imagem:

Considerações
Existem diversas formas de chegarmos neste mesmo resultado, a que apresentei aqui é apenas uma delas.
Deixo como sugestão, além de estudar o código, construir uma nova função para que a transição das imagens pause enquanto o botão do mouse estiver pressionado em cima da imagem.
Link com o exemplo e código completo:
https://stackblitz.com/edit/consolelog-construindo-componente-stories?file=src/main.ts