Animação de preenchimento HTML5 SVG com CSS3 e Vanilla JavaScript
Publicados: 2022-03-10SVG significa Scalable V ector G raphics e é uma linguagem de marcação padrão baseada em XML para gráficos vetoriais. Ele permite desenhar caminhos, curvas e formas determinando um conjunto de pontos no plano 2D. Além disso, você pode adicionar propriedades de contração nesses caminhos (como traçado, cor, espessura, preenchimento e muito mais) para produzir animações.
Desde abril de 2017, o Módulo de preenchimento e traçado CSS nível 3 permite que cores SVG e padrões de preenchimento sejam definidos a partir de uma folha de estilo externa, em vez de definir atributos em cada elemento. Neste tutorial, usaremos uma cor hexadecimal simples, mas as propriedades de preenchimento e traçado também aceitam padrões, gradientes e imagens como valores.
Nota : Ao visitar o site da Awwwards, a exibição da nota animada só pode ser visualizada com a largura do navegador definida para 1024px ou mais.
- Demonstração: projeto de exibição de notas
- Repo: Repositório de Exibição de Notas
Estrutura do arquivo
Vamos começar criando os arquivos no terminal:
mkdir note-display cd note-display touch index.html styles.css scripts.js
HTML
Aqui está o modelo inicial que vincula os arquivos css
e js
:
<html lang="en"> <head> <meta charset="UTF-8"> <title>Note Display</title> <link rel="stylesheet" href="./styles.css"> </head> <body> <script src="./scripts.js"></script> </body> </html>
Cada elemento de nota consiste em um item de lista: li
que contém o circle
, o valor da note
e seu label
.
O .circle_svg
é um elemento SVG, que envolve dois elementos <circle>. O primeiro é o caminho a ser preenchido enquanto o segundo é o preenchimento que será animado.
A note
é separada em números inteiros e decimais para que diferentes tamanhos de fonte possam ser aplicados a elas. O label
é um <span>
simples. Então, juntando tudo isso fica assim:
<li class="note-display"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Transparent</span> </li>
Os atributos cx
e cy
definem o ponto central do eixo x e do eixo y do círculo. O atributo r
define seu raio.
Você provavelmente notou o padrão de sublinhado/traço nos nomes das classes. Isso é BEM, que significa block
, element
e modifier
. É uma metodologia que torna a nomeação de seus elementos mais estruturada, organizada e semântica.
Leitura recomendada : Uma explicação do BEM e por que você precisa dele
Para finalizar as estruturas do template, vamos agrupar os quatro itens de lista em um elemento de lista não ordenado:
<ul class="display-container"> <li class="note-display"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Transparent</span> </li> <li class="note-display"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Reasonable</span> </li> <li class="note-display"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Usable</span> </li> <li class="note-display"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Exemplary</span> </li> </ul>
Você deve estar se perguntando o que significam os rótulos Transparent
, Reasonable
, Usable
e Exemplary
. Quanto mais você se familiarizar com a programação, perceberá que escrever código não é apenas tornar o aplicativo funcional, mas também garantir que ele seja sustentável e escalável a longo prazo. Isso só é alcançado se o seu código for fácil de alterar.
“A sigla TRUE
deve ajudar a decidir se o código que você escreve será capaz de acomodar mudanças no futuro ou não.”
Então, da próxima vez, pergunte-se:
-
Transparent
: As consequências das alterações de código são claras? -
Reasonable
: O custo benefício vale a pena? -
Usable
: Poderei reutilizá-lo em cenários inesperados? -
Exemplary
: Apresenta alta qualidade como exemplo para código futuro?
Nota : “Practical Object-Oriented Design in Ruby” de Sandi Metz explica TRUE
juntamente com outros princípios e como alcançá-los através de padrões de projeto. Se você ainda não dedicou algum tempo para estudar padrões de design, considere adicionar este livro à sua leitura antes de dormir.
CSS
Vamos importar as fontes e aplicar um reset em todos os itens:
@import url('https://fonts.googleapis.com/css?family=Nixie+One|Raleway:200'); * { padding: 0; margin: 0; box-sizing: border-box; }
A propriedade box-sizing: border-box
inclui valores de preenchimento e de borda na largura e altura totais de um elemento, portanto, é mais fácil calcular suas dimensões.
Nota : Para obter uma explicação visual sobre box-sizing
, leia “Facilite sua vida com dimensionamento de caixas CSS”.
body { height: 100vh; color: #fff; display: flex; background: #3E423A; font-family: 'Nixie One', cursive; } .display-container { margin: auto; display: flex; }
Combinando as regras display: flex
no body
e margin-auto
no .display-container
, é possível centralizar o elemento filho verticalmente e horizontalmente. O elemento .display-container
também será um flex-container
; dessa forma, seus filhos serão colocados na mesma linha ao longo do eixo principal.
O item de lista .note-display
também será um flex-container
. Como existem muitos filhos para centralização, vamos fazer isso através das propriedades justify-content
e align-items
. Todos flex-items
serão centralizados ao longo do eixo cross
e main
. Se você não tiver certeza do que são, confira a seção de alinhamento em “Guia Visual de Fundamentos do CSS Flexbox”.
.note-display { display: flex; flex-direction: column; align-items: center; margin: 0 25px; }
Vamos aplicar um traço aos círculos definindo as regras stroke-width
, stroke-opacity
e stroke-linecap
que estilizam as extremidades ao vivo do traço. Em seguida, vamos adicionar uma cor a cada círculo:
.circle__progress { fill: none; stroke-width: 3; stroke-opacity: 0.3; stroke-linecap: round; } .note-display:nth-child(1) .circle__progress { stroke: #AAFF00; } .note-display:nth-child(2) .circle__progress { stroke: #FF00AA; } .note-display:nth-child(3) .circle__progress { stroke: #AA00FF; } .note-display:nth-child(4) .circle__progress { stroke: #00AAFF; }
Para posicionar o elemento percent
de forma absoluta, é necessário saber absolutamente para quê. O elemento .circle
deve ser a referência, então vamos adicionar position: relative
a ele.
Nota : Para uma explicação visual mais profunda sobre o posicionamento absoluto, leia “Como entender a posição absoluta do CSS de uma vez por todas”.
Outra maneira de centralizar elementos é combinar top: 50%
, left: 50%
e transform: translate(-50%, -50%);
que posicionam o centro do elemento no centro de seu pai.
.circle { position: relative; } .percent { width: 100%; top: 50%; left: 50%; position: absolute; font-weight: bold; text-align: center; line-height: 28px; transform: translate(-50%, -50%); } .percent__int { font-size: 28px; } .percent__dec { font-size: 12px; } .label { font-family: 'Raleway', serif; font-size: 14px; text-transform: uppercase; margin-top: 15px; }
Até agora, o modelo deve estar assim:
Transição de preenchimento
A animação do círculo pode ser criada com a ajuda de duas propriedades SVG do círculo: stroke-dasharray
e stroke-dashoffset
.
“ stroke-dasharray
define o padrão de intervalo de traço em um traço.”
Pode assumir até quatro valores:
- Quando é definido como um único inteiro (
stroke-dasharray: 10
), traços e lacunas têm o mesmo tamanho; - Para dois valores (
stroke-dasharray: 10 5
), o primeiro é aplicado aos traços, o segundo às lacunas; - A terceira e quarta formas (
stroke-dasharray: 10 5 2
estroke-dasharray: 10 5 2 3
) gerarão traços e lacunas em vários tamanhos.
A imagem à esquerda mostra a propriedade stroke-dasharray
sendo definida de 0 a 238px, que é o comprimento da circunferência do círculo.
A segunda imagem representa a propriedade stroke-dashoffset
que desloca o início da matriz de traço. Também é definido de 0 ao comprimento da circunferência do círculo.
Para produzir o efeito de preenchimento, definiremos o stroke-dasharray
para o comprimento da circunferência, de modo que todo o seu comprimento seja preenchido com um traço grande e sem lacunas. Também o compensaremos pelo mesmo valor, para que fique “oculto”. Então o stroke-dashoffset
será atualizado para o valor da nota correspondente, preenchendo o traço de acordo com a duração da transição.
A atualização das propriedades será feita nos scripts através de Variáveis CSS. Vamos declarar as variáveis e definir as propriedades:
.circle__progress--fill { --initialStroke: 0; --transitionDuration: 0; stroke-opacity: 1; stroke-dasharray: var(--initialStroke); stroke-dashoffset: var(--initialStroke); transition: stroke-dashoffset var(--transitionDuration) ease; }
Para definir o valor inicial e atualizar as variáveis, vamos começar selecionando todos os elementos .note-display
com document.querySelectorAll
. A transitionDuration
será definida como 900
milissegundos.
Em seguida, iteramos pelo array displays, selecionamos seu .circle__progress.circle__progress--fill
e extraímos o atributo r
definido no HTML para calcular o comprimento da circunferência. Com isso, podemos definir os valores iniciais --dasharray
e --dashoffset
.
A animação ocorrerá quando a variável --dashoffset
for atualizada por um setTimeout de 100ms:
const displays = document.querySelectorAll('.note-display'); const transitionDuration = 900; displays.forEach(display => { let progress = display.querySelector('.circle__progress--fill'); let radius = progress.r.baseVal.value; let circumference = 2 * Math.PI * radius; progress.style.setProperty('--transitionDuration', `${transitionDuration}ms`); progress.style.setProperty('--initialStroke', circumference); setTimeout(() => progress.style.strokeDashoffset = 50, 100); });
Para obter a transição a partir do topo, o elemento .circle__svg
deve ser girado:
.circle__svg { transform: rotate(-90deg); }
Agora, vamos calcular o valor do dashoffset
— relativo à nota. O valor da nota será inserido em cada item li
através do atributo data-*. O *
pode ser trocado por qualquer nome que atenda às suas necessidades e pode então ser recuperado em JavaScript através do conjunto de dados do elemento: element.dataset.*
.
Observação : você pode ler mais sobre o atributo data-* no MDN Web Docs.
Nosso atributo será chamado de “ data-note
”:
<ul class="display-container"> + <li class="note-display" data-note="7.50"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Transparent</span> </li> + <li class="note-display" data-note="9.27"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Reasonable</span> </li> + <li class="note-display" data-note="6.93"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Usable</span> </li> + <li class="note-display" data-note="8.72"> <div class="circle"> <svg width="84" height="84" class="circle__svg"> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--path"></circle> <circle cx="41" cy="41" r="38" class="circle__progress circle__progress--fill"></circle> </svg> <div class="percent"> <span class="percent__int">0.</span> <span class="percent__dec">00</span> </div> </div> <span class="label">Exemplary</span> </li> </ul>
O método parseFloat
converterá a string retornada por display.dataset.note
em um número de ponto flutuante. O offset
representa a porcentagem que falta para atingir a pontuação máxima. Assim, para uma nota de 7.50
, teríamos (10 - 7.50) / 10 = 0.25
, o que significa que o comprimento da circumference
deve ser compensado em 25%
do seu valor:
let note = parseFloat(display.dataset.note); let offset = circumference * (10 - note) / 10;
Atualizando o scripts.js
:
const displays = document.querySelectorAll('.note-display'); const transitionDuration = 900; displays.forEach(display => { let progress = display.querySelector('.circle__progress--fill'); let radius = progress.r.baseVal.value; let circumference = 2 * Math.PI * radius; + let note = parseFloat(display.dataset.note); + let offset = circumference * (10 - note) / 10; progress.style.setProperty('--initialStroke', circumference); progress.style.setProperty('--transitionDuration', `${transitionDuration}ms`); + setTimeout(() => progress.style.strokeDashoffset = offset, 100); });
Antes de prosseguirmos, vamos extrair a transição do stoke para seu próprio método:
const displays = document.querySelectorAll('.note-display'); const transitionDuration = 900; displays.forEach(display => { - let progress = display.querySelector('.circle__progress--fill'); - let radius = progress.r.baseVal.value; - let circumference = 2 * Math.PI * radius; let note = parseFloat(display.dataset.note); - let offset = circumference * (10 - note) / 10; - progress.style.setProperty('--initialStroke', circumference); - progress.style.setProperty('--transitionDuration', `${transitionDuration}ms`); - setTimeout(() => progress.style.strokeDashoffset = offset, 100); + strokeTransition(display, note); }); + function strokeTransition(display, note) { + let progress = display.querySelector('.circle__progress--fill'); + let radius = progress.r.baseVal.value; + let circumference = 2 * Math.PI * radius; + let offset = circumference * (10 - note) / 10; + progress.style.setProperty('--initialStroke', circumference); + progress.style.setProperty('--transitionDuration', `${transitionDuration}ms`); + setTimeout(() => progress.style.strokeDashoffset = offset, 100); + }
Aumento do valor da nota
Ainda há a transição da nota de 0.00
para o valor da nota a ser construída. A primeira coisa a fazer é separar os valores inteiros e decimais. Usaremos o método de string split()
(pega um argumento que determina onde a string será quebrada e retorna um array contendo ambas as strings quebradas). Eles serão convertidos em números e passados como argumentos para a função increaseNumber()
, junto com o elemento display
e um sinalizador indicando se é um inteiro ou um decimal.
const displays = document.querySelectorAll('.note-display'); const transitionDuration = 900; displays.forEach(display => { let note = parseFloat(display.dataset.note); + let [int, dec] = display.dataset.note.split('.'); + [int, dec] = [Number(int), Number(dec)]; strokeTransition(display, note); + increaseNumber(display, int, 'int'); + increaseNumber(display, dec, 'dec'); });
Na função raiseNumber increaseNumber()
, selecionamos o elemento .percent__int
ou .percent__dec
, dependendo do className
, e também caso a saída deva conter um ponto decimal ou não. Definimos nossa transitionDuration
como 900ms
. Agora, para animar um número de 0 a 7, por exemplo, a duração tem que ser dividida pela nota 900 / 7 = 128.57ms
. O resultado representa quanto tempo cada iteração de aumento levará. Isso significa que nosso setInterval
será acionado a cada 128.57ms
.
Com essas variáveis definidas, vamos definir o setInterval
. A variável counter
será anexada ao elemento como texto e aumentada em cada iteração:
function increaseNumber(display, number, className) { let element = display.querySelector(`.percent__${className}`), decPoint = className === 'int' ? '.' : '', interval = transitionDuration / number, counter = 0; let increaseInterval = setInterval(() => { element.textContent = counter + decPoint; counter++; }, interval); }
Frio! Aumenta os valores, mas meio que faz isso para sempre. Precisamos limpar o setInterval
quando as notas atingirem o valor que desejamos. Isso é feito com a função clearInterval
:
function increaseNumber(display, number, className) { let element = display.querySelector(`.percent__${className}`), decPoint = className === 'int' ? '.' : '', interval = transitionDuration / number, counter = 0; let increaseInterval = setInterval(() => { + if (counter === number) { window.clearInterval(increaseInterval); } element.textContent = counter + decPoint; counter++; }, interval); }
Agora o número é atualizado até o valor da nota e limpo com a função clearInterval()
.
Isso é muito bonito para este tutorial. Espero que tenha gostado!
Se você quiser construir algo um pouco mais interativo, confira meu Tutorial do Jogo da Memória criado com Vanilla JavaScript. Abrange conceitos básicos de HTML5, CSS3 e JavaScript, como posicionamento, perspectiva, transições, Flexbox, manipulação de eventos, tempos limite e ternários.
Boa codificação!