Animando um personagem com CSS e JavaScript

Publicado em 10 jul 2020. Uns 10 minutos de leitura.

CSS é uma parada poderosa. Apesar de, originalmente, servir para suportar o HTML e definir estilos para itens em um documento, a comunidade de desenvolvimento front-end mostrou que dá para fazer muito mais que isso e cria verdadeiras experiências com CSS, substituindo ferramentas de animação e imagens por linhas de código de folha de estilos. Juntando isso com o dinamismo de JavaScript, dá para criar interações bem legais diretamente na web, muitas vezes sem assets extra além do próprio código.

Esse post é um experimento para criar algo que não é novo, mas que é divertido e mostra algumas das capacidades de criar animações interativas com CSS e JS. Minha ideia é tentar fazer uma animação para acompanhar um formulário de login sem usar imagens ou outros recursos além de HTML, CSS e JS. Eu não sou um artista então não espere uma obra de arte. A ideia é, com pouco código, fazer um efeito que fique legal.

Como ficou

Interaja com os campos de nome de usuário e senha abaixo para animar o personagem.

O código

Esse é o código final. Logo abaixo, vou comentar cada trecho de código, ressaltar os pontos mais interessantes e explicar algumas partes que não são tão claras de cara.

<div id="login-form">
  <div class="character">
    <div class="eyes">
      <div class="eye"></div>
      <div class="eye"></div>
    </div>
  </div>
  <input class="username" type="text" placeholder="username" maxlength="20">
  <input class="password" type="password" placeholder="password">
</div>
#login-form {
  --size: 300px;
  
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  align-items: center;
  padding: 10%;
  width: var(--size);
  min-height: var(--size);
  border-radius: 10%;
  background-color: #d9d1d1;
}

#login-form input {
  padding: 0.25em;
  border-radius: 0.5em;
}

.character {
  display: flex;
  flex-direction: column;
  justify-content: space-around;
  align-items: center;
  width: calc(var(--size) / 3);
  height: calc(var(--size) / 3);
  border-radius: 40% 40% 0 0;
  background-color: #d95151;
}

.character .eyes {
  --eye-ball-offset: 50%;
  --eye-ball-size: 35%;

  display: flex;
  justify-content: space-evenly;
  width: 80%;
}

.character .eyes .eye {
  position: relative;
  transition: height 0.2s ease-in;
  overflow: hidden;
  width: 35%;
  border-radius: 50%;
  background-color: #190000;
}

.character .eyes .eye::before {
  content: '';
  display: block;
  transition: padding-top 0.2s ease-in;
  padding-top: 100%;
}

.character .eyes.closed .eye::before {
  padding-top: 2px;
}

.character .eyes .eye::after {
  content: '';
  position: absolute;
  bottom: 5%;
  left: var(--eye-ball-offset);
  transform: translateX(-50%);
  transition: all 0.2s ease-in;
  width: var(--eye-ball-size);
  height: var(--eye-ball-size);
  border-radius: 50%;
  background-color: #faffff;
}

.character .eyes.closed .eye::after {
  height: 0;
}
const loginForm = document.querySelector('#login-form')
const characterEyes = loginForm.querySelector('.eyes')
const usernameInput = loginForm.querySelector('.username')
const passwordInput = loginForm.querySelector('.password')

function updateEyeballPosition(value) {
  if (typeof value !== 'number') {
    const offset = usernameInput.value.length * (100 / usernameInput.maxLength)
    value = Math.max(Math.min(offset, 90), 10)
  }

  characterEyes.style.setProperty('--eye-ball-offset', `${value}%`);
}

usernameInput.addEventListener('keyup', () => updateEyeballPosition())
usernameInput.addEventListener('focus', () => updateEyeballPosition())
usernameInput.addEventListener('blur', () => updateEyeballPosition(50))

passwordInput.addEventListener('focus', () => characterEyes.classList.add('closed'))
passwordInput.addEventListener('blur', () => characterEyes.classList.remove('closed'))

Destrinchando o CSS

O HTML é razoavelmente auto-explicativo: temos um um personagem e dois campos de texto, um deles para senha. Dentro do personagem, temos divs de estrutura com elementos necessários pra construir o desenho e a animação. Eu normalmente crio um elemento raíz e saio criando mais elementos quando vejo que vão ser necessários, como nesse caso foram os olhos (um container em volta deles e um div pra cada olho). As pupilas eu deixei para criar pseudoelementos com CSS.

O grosso do código está no CSS. Eu tentei definir medidas relativas dentro do personagem sempre que possível, para que escalá-lo fosse mais fácil. Deixei como asboluto apenas o tamanho dele (300px).

No personagem, eu usei bastante flexbox. É um tipo de layout (display: flex) que permite que o elemento defina como os elementos diretamente descendentes vão se posicionar. No caso do elemento raíz do personagem, a direção dos elementos é na vertical (flex-direction: column). Eu tinha planos para por uma boca no início, mas terminei deixando sem. De toda maneira, colocar na vertical permite centralizar os olhos usando espaçamento em volta do (único) descendente (justify-content: space-around).

Dentro do elementos que contém os olhos do personagem (.eyes), vamos ter dois elementos, um para cada olho (.eye). Eles também estão alinhados dentro usando flex, mas dessa vez a direção é na horizontal (row). Como é o padrão, não precisa especificar.

Agora vem um dos pulos do gato: os olhos. Eu queria fazer os olhos serem retangulares (redondos na verdade, mas para chegar no círculo começamos em um quadrado). Porém, não queria definir valores absolutos para a largura e altura de cada olho. Ao invés disso, queria definir uma largura proporcional a largura do personagem e que a altura do olho fosse igual a largura dele. Só que não é tão fácil.

Quando a gente diz que um elemento tem width: 50%, a gente quer dizer que ele tem metade da largura (width) do parente. Quando a gente diz que ele tem height: 50%, ele vai ter metade da altura (height) do parente. A linha dos olhos não tem uma altura definida, então um percentual sobre a altura iria resultar em zero, se não tiver nenhum outro elemento dentro que defina uma altura.

A solução pro meu problema era em teoria, simples: eu queria poder especificar a altura do olho em função da largura do parente, igual como fiz com a própria largura do olho. Mas não consigo fazer isso diretamente. Ora, se a largura do olho já está em função da largura do parente, se eu conseguir fazer a altura do olho em função da largura do olho, dá pra resolver o problema!

Você, querida pessoa que está lendo este post, sabe como é calculado um padding relativo (percentual)? Se você respondeu "em função da largura do parente", você acertou! Um padding-top de 50%, por exemplo, vai adicionar um espaçamento interno no topo do elemento do tamanho de metade da largura do parente. Isso me ajuda! Dá para criar um elemento dentro do olho e colocar o padding-top dele como 100%. Assim, eu vou ter um elemento filho do olho, sem conteúdo mas com padding, que vai resultar numa altura igual a 100% da largura do olho.

Parece complicado? É porque é mesmo. Isso seria facilmente resolvido se CSS tivesse uma propriedade como aspect-ratio: 1:1 por exemplo. Mas enfim, isso funciona. Se você procurar no HTML, não vai encontrar um elemento dentro de .eye. Neste caso, eu usei um pseudoelemento via CSS, o ::before. Ele representa um elemento dentro do olho, que vem antes do conteúdo filho do olho (que não existe, nesse caso). Para um pseudoelemento existir, ele precisa ter um valor no content, nem que seja a string vazia ('').

Para o efeito de fechar o olho, vamos animar o padding do pseudoelemento também. Por isso, já deixei pronto um transition. Quando os olhos estiverem fechados, vamos adicionar a classe .closed no container dos olhos (para não ter que adicionar uma vez em cada olho). Assim, quando os olhos estiverem dentro de um .eyes.closed, seus pseudoelementos ::before vão ter o padding fixo em 2px. Resolvi usar um valor absoluto neste padding, para deixar o olho quase sumindo não importa o tamanho do personagem.

Enfim, vem outra parte um pouco mais complicada que é animar a pupila. Para criar a pupila, eu usei outro pseudoelemento, o ::after. Ele tem a mesma lógica do primeiro, mas vem depois do conteúdo do olho (que não existe), e depois do ::before por consequência. Para animar sua posição, resolvi deixá-lo com position: absolute. Para que a posição seja em relação ao olho que contem a pupila, o olho tem position: relative.

Querendo facilitar minha vida, para não ter que levar em conta o tamanho da pupila na hora de animar sua posição, usei o transform: translateX(-50%) para deslocar a pupila para a esquerda em metade da sua largura. Isso não afeta a posição do elemento, apenas onde ele vai ser desenhado. Com isso, a posição left do elemento aponta para o centro da pupila, e não mais para o canto. Um left de 0% deixa a pupila metade dentro, metade fora do lado esquerdo do olho, e um left de 100% faz o mesmo só que na direita do olho. Para a pupila não aparecer fora do olho, ele tem overflow: hidden.

Como a pupila vai ser animada tanto no left, como no height (para ela fechar junto com o olho), coloquei transition: all para facilitar minha vida, mas poderia ter escrito para as duas propriedades.

Destrinchando o JavaScript

Para esse exemplo, usamos apenas JavaScript nativo do navegador, sem dependências ou bibliotecas, manipulando elementos do DOM diretamente. Refazer essa lógica com um Virtual DOM, como o do React, deixaria o código mais funcional (podendo criar um componente personagem sem estado), e quem sabe eu refaça uma versão desse exemplo com React.

Temos dois comportamentos que queremos controlar: o movimento da pupila conforme o usuário digita e o abrir/fechar dos olhos quando o campo de senha é utilizado.

Começando pelos abrir e fechar dos olhos, que é mais simples: quando o input de senha recebe o foco (ou seja, é selecionado para digitação, e recebe o evento de focus), adicionamos a classe .closed no container dos olhos (.eyes). Isso vai mudar o padding-top do pseudoelemento ::before de cada olho para 2px, que vai ser animado por conta da transition. Quando o usuário sair desse campo e ele perder o foco (o evento de blur), nós retiramos a classe. Simples assim.

Agora, para movimentar as pupilas. Eu criei uma função que troca o left das pupilas através de uma variável de CSS, a --eye-ball-offset. Eu não precisava usar uma variável de CSS para isso, mas facilita para mudar o posicionamento nos dois olhos ao mesmo tempo. Mudar a variável em um elemento altera o valor para todos os descendentes deste elemento, por isso coloquei a variável no container dos olhos (.eyes).

Essa função, se receber um parâmetro e ele for um número, vai interpretá-lo como um número percentual (0-100) e trocar o valor da variável por esse valor (com um % no fim). Essa versão com o argumento eu uso quando o usuário sai do campo de username (evento de blur), para centralizar as pupilas em 50% novamente.

Já nos eventos de focus e keyup (quando uma tecla termina de ser pressionada dentro do campo), eu chamo a função acima sem argumentos. Nesse caso, ela vai pegar o campo de usuário e calcular quanto texto já foi entrado. Note que há uma leve "trapaça" para não termos que saber realmente onde o cursor do usuário está: o campo de texto tem um limite de caracteres. No caso do exemplo, no HTML, o limite está em 20. Assim, na função, nós pegamos o número de caracteres que o usuário digitou e dividimos por esse limite. O resultado é um número de 0 a 1, que nós multiplicamos por 100 e limitamos para que fique sempre entre 10 e 90 (valores arbitrários que achei que ficou visualmente agradável para limitar o movimento das pupilas).

Isso tem algumas consequências: se o campo de texto for muito pequeno, a pupila vai continuar correndo quando o usuário chegar na borda da direita do campo e continuar digitando. Se o nome de usuário for feito por caracteres muito finos (como "iiiiiiiii"), a pupila vai se mover mais rápido que o cursor do campo de texto. Esses problemas podem ser mitigados usando uma fonte monoespaçada e garantindo que na largura vai caber o número de caracteres do limite do campo de texto, mas não me preocupei muito com isso pois, no caso medio, o efeito funciona bem o suficiente.

É isso que tem pra hoje! Eu gostei do resultado, ficou um bonequinho razoavelmente agradável com pouco código. Dá para fazer muito mais com mais paciência (e noções de estética). Uma inspiração que eu tenho nessa linha é Talita Oliveira, que faz altos desenhos interessantes com CSS! Fica a recomendação de dar uma olhada e se inspirar também.