Como criar um componente de dica

Uma visão geral básica de como criar um elemento de dica de ferramenta personalizado acessível e adaptável a cores.

Nesta postagem, quero compartilhar minhas ideias sobre como criar um elemento personalizado <tool-tip> adaptável a cores e acessível. Teste a demonstração e veja a fonte.

Uma dica é mostrada funcionando em vários exemplos e esquemas de cores

Se preferir vídeo, confira uma versão desta postagem no YouTube:

Visão geral

Uma dica é uma sobreposição não modal, não bloqueadora e não interativa que contém informações complementares para interfaces de usuário. Ele fica oculto por padrão e é mostrado quando um elemento associado é passado com o cursor ou recebe foco. Não é possível selecionar ou interagir diretamente com uma dica. As dicas não substituem rótulos ou outras informações de alto valor. Um usuário precisa conseguir concluir a tarefa sem uma dica.

O que fazer: sempre rotule suas entradas.
Não: confie em dicas em vez de rótulos

Toggletip x dica

Assim como muitos componentes, há várias descrições do que é uma dica, por exemplo, em MDN, WAI ARIA, Sarah Higley e Inclusive Components. Gosto da separação entre dicas de ferramentas e dicas de alternância. Uma dica precisa conter informações complementares não interativas, enquanto uma dica de alternância pode conter interatividade e informações importantes. O principal motivo da divisão é a acessibilidade. Como os usuários devem navegar até o pop-up e ter acesso às informações e aos botões nele? As dicas interativas ficam complexas rapidamente.

Confira um vídeo de uma dica de alternância do site Designcember (link em inglês). Uma sobreposição com interatividade que um usuário pode fixar e explorar e depois fechar com dispensa leve ou a tecla "Esc":

Este desafio de GUI usou uma dica de ferramenta, tentando fazer quase tudo com CSS. Veja como criar uma.

Marcação

Optei por usar um elemento personalizado <tool-tip>. Os autores não precisam transformar elementos personalizados em componentes da Web se não quiserem. O navegador vai tratar <foo-bar> como um <div>. Pense em um elemento personalizado como um nome de classe com menos especificidade. Não há JavaScript envolvido.

<tool-tip>A tooltip</tool-tip>

É como uma div com algum texto dentro. Podemos adicionar [role="tooltip"] para se conectar à árvore de acessibilidade de leitores de tela compatíveis.

<tool-tip role="tooltip">A tooltip</tool-tip>

Agora, para leitores de tela, ele é reconhecido como uma dica. Veja no exemplo a seguir como o primeiro elemento de link tem um elemento de dica de ferramenta reconhecido na árvore, e o segundo não. O segundo não tem a função. Na seção de estilos, vamos melhorar essa visualização em árvore.

Uma captura de tela da árvore de acessibilidade do Chrome DevTools representando o HTML. Mostra um
link com o texto &quot;top ; Has tooltip: Hey, a tooltip!&quot; que pode ser colocado em foco. Dentro dele, há o texto estático &quot;top&quot; e um elemento de dica.

Em seguida, precisamos que a dica não seja focalizável. Se um leitor de tela não entender a função da dica, ele vai permitir que os usuários foquem o <tool-tip> para ler o conteúdo, e a experiência do usuário não precisa disso. Os leitores de tela anexarão o conteúdo ao elemento pai e, portanto, não precisam de foco para serem acessíveis. Aqui, podemos usar inert para garantir que nenhum usuário encontre acidentalmente o conteúdo da dica no fluxo de guias:

<tool-tip inert role="tooltip">A tooltip</tool-tip>

Outra captura de tela da árvore de acessibilidade do Chrome DevTools. Desta vez, o elemento
tooltip está faltando.

Depois, escolhi usar atributos como a interface para especificar a posição da dica. Por padrão, todos os <tool-tip>s assumem uma posição "superior", mas a posição pode ser personalizada em um elemento adicionando tip-position:

<tool-tip role="tooltip" tip-position="right ">A tooltip</tool-tip>

Uma captura de tela de um link com uma dica à direita dizendo &quot;Uma dica&quot;.

Eu costumo usar atributos em vez de classes para coisas assim, para que o <tool-tip> não possa ter várias posições atribuídas a ele ao mesmo tempo. Pode haver apenas um ou nenhum.

Por fim, coloque elementos <tool-tip> dentro do elemento para o qual você quer fornecer uma dica. Aqui, compartilho o texto alt com usuários videntes colocando uma imagem e um <tool-tip> dentro de um elemento <picture>:

<picture>
  <img alt="The GUI Challenges skull logo" width="100" src="...">
  <tool-tip role="tooltip" tip-position="bottom">
    The <b>GUI Challenges</b> skull logo
  </tool-tip>
</picture>

Uma captura de tela de uma imagem com uma dica que diz &quot;O logotipo de caveira dos Desafios da GUI&quot;.

Aqui, coloco um <tool-tip> dentro de um elemento <abbr>:

<p>
  The <abbr>HTML <tool-tip role="tooltip" tip-position="top">Hyper Text Markup Language</tool-tip></abbr> abbr element.
</p>

Uma captura de tela de um parágrafo com a sigla HTML sublinhada e uma dica acima dela dizendo &quot;Hyper Text Markup Language&quot;.

Acessibilidade

Como escolhi criar dicas de ferramentas e não dicas de alternância, esta seção é muito mais simples. Primeiro, vamos definir a experiência do usuário desejada:

  1. Em espaços limitados ou interfaces desordenadas, oculte as mensagens complementares.
  2. Quando um usuário passa o cursor, foca ou usa o toque para interagir com um elemento, revele a mensagem.
  3. Quando o passar o cursor, o foco ou o toque terminar, oculte a mensagem novamente.
  4. Por fim, reduza qualquer movimento se um usuário tiver especificado uma preferência por movimento reduzido.

Nosso objetivo é oferecer mensagens complementares sob demanda. Um usuário com visão pode passar o cursor do mouse ou do teclado para revelar a mensagem e lê-la com os olhos. Um usuário de leitor de tela sem visão pode focar para revelar a mensagem, recebendo-a por áudio pela ferramenta.

Captura de tela do VoiceOver do macOS lendo um link com uma dica

Na seção anterior, abordamos a árvore de acessibilidade, a função da dica e inert. O que resta é testar e verificar se a experiência do usuário revela a mensagem da dica de ferramenta ao usuário. Ao testar, não fica claro qual parte da mensagem audível é uma dica. Ele também pode ser visto durante a depuração na árvore de acessibilidade. O texto do link "top" é executado junto, sem hesitação, com "Look, tooltips!". O leitor de tela não quebra nem identifica o texto como conteúdo de dica.

Uma captura de tela da árvore de acessibilidade do Chrome DevTools em que o texto do link diz &quot;top Hey, a tooltip!&quot;.

Adicione um pseudoelemento somente para leitores de tela ao <tool-tip> e inclua seu próprio texto de solicitação para usuários sem visão.

&::before {
  content: "; Has tooltip: ";
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(50%);
  height: 1px;
  width: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
}

Abaixo, você pode conferir a árvore de acessibilidade atualizada, que agora tem um ponto e vírgula após o texto do link e um aviso para a dica "Tem dica: ".

Uma captura de tela atualizada da árvore de acessibilidade do Chrome DevTools em que o texto do link tem uma fraseologia melhorada, &quot;top ; Has tooltip: Hey, a tooltip!&quot;.

Agora, quando um usuário de leitor de tela foca o link, ele diz "topo", faz uma pequena pausa e anuncia "tem dica: veja, dicas". Isso dá ao usuário do leitor de tela algumas dicas úteis de UX. A hesitação oferece uma boa separação entre o texto do link e a dica. Além disso, quando "tem dica" é anunciado, um usuário de leitor de tela pode cancelar facilmente se já tiver ouvido antes. É muito parecido com passar e tirar o cursor rapidamente, já que você já viu a mensagem complementar. Isso parece uma boa paridade de UX.

Estilos

O elemento <tool-tip> será filho do elemento que representa a mensagem complementar. Portanto, vamos começar com o essencial para o efeito de sobreposição. Remova do fluxo de documentos com o position absolute:

tool-tip {
  position: absolute;
  z-index: 1;
}

Se o elemento pai não for um contexto de empilhamento, a dica vai se posicionar no mais próximo, o que não é o ideal. Há um novo seletor no bloco que pode ajudar, :has():

Browser Support

  • Chrome: 105.
  • Edge: 105.
  • Firefox: 121.
  • Safari: 15.4.

Source

:has(> tool-tip) {
  position: relative;
}

Não se preocupe muito com o suporte do navegador. Primeiro, lembre-se de que essas dicas são complementares. Se eles não funcionarem, não tem problema. Em segundo lugar, na seção JavaScript, vamos implantar um script para fazer polyfill da funcionalidade necessária para navegadores sem suporte a :has().

Em seguida, vamos tornar as dicas não interativas para que elas não roubem eventos de ponteiro do elemento pai:

tool-tip {
  
  pointer-events: none;
  user-select: none;
}

Em seguida, oculte a dica com opacidade para que possamos fazer a transição com um fade cruzado:

tool-tip {
  opacity: 0;
}

:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
}

:is() e :has() fazem o trabalho pesado aqui, fazendo com que tool-tip que contém elementos principais reconheça a interatividade do usuário para alternar a visibilidade de uma dica de ferramenta secundária. Os usuários de mouse podem passar o cursor, os usuários de teclado e leitor de tela podem focar, e os usuários de toque podem tocar.

Agora que a sobreposição de mostrar e ocultar está funcionando para usuários com visão, é hora de adicionar alguns estilos para temas, posicionamento e adição da forma de triângulo ao balão. Os estilos a seguir começam a usar propriedades personalizadas, aproveitando o que temos até agora, mas também adicionando sombras, tipografia e cores para que pareça uma dica flutuante:

Uma captura de tela da dica no modo escuro, flutuando sobre o link &quot;block-start&quot;.

tool-tip {
  --_p-inline: 1.5ch;
  --_p-block: .75ch;
  --_triangle-size: 7px;
  --_bg: hsl(0 0% 20%);
  --_shadow-alpha: 50%;

  --_bottom-tip: conic-gradient(from -30deg at bottom, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) bottom / 100% 50% no-repeat;
  --_top-tip: conic-gradient(from 150deg at top, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) top / 100% 50% no-repeat;
  --_right-tip: conic-gradient(from -120deg at right, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) right / 50% 100% no-repeat;
  --_left-tip: conic-gradient(from 60deg at left, rgba(0,0,0,0), #000 1deg 60deg, rgba(0,0,0,0) 61deg) left / 50% 100% no-repeat;

  pointer-events: none;
  user-select: none;

  opacity: 0;
  transform: translateX(var(--_x, 0)) translateY(var(--_y, 0));
  transition: opacity .2s ease, transform .2s ease;

  position: absolute;
  z-index: 1;
  inline-size: max-content;
  max-inline-size: 25ch;
  text-align: start;
  font-size: 1rem;
  font-weight: normal;
  line-height: normal;
  line-height: initial;
  padding: var(--_p-block) var(--_p-inline);
  margin: 0;
  border-radius: 5px;
  background: var(--_bg);
  color: CanvasText;
  will-change: filter;
  filter:
    drop-shadow(0 3px 3px hsl(0 0% 0% / var(--_shadow-alpha)))
    drop-shadow(0 12px 12px hsl(0 0% 0% / var(--_shadow-alpha)));
}

/* create a stacking context for elements with > tool-tips */
:has(> tool-tip) {
  position: relative;
}

/* when those parent elements have focus, hover, etc */
:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
  transition-delay: 200ms;
}

/* prepend some prose for screen readers only */
tool-tip::before {
  content: "; Has tooltip: ";
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(50%);
  height: 1px;
  width: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
}

/* tooltip shape is a pseudo element so we can cast a shadow */
tool-tip::after {
  content: "";
  background: var(--_bg);
  position: absolute;
  z-index: -1;
  inset: 0;
  mask: var(--_tip);
}

/* top tooltip styles */
tool-tip:is(
  [tip-position="top"],
  [tip-position="block-start"],
  :not([tip-position]),
  [tip-position="bottom"],
  [tip-position="block-end"]
) {
  text-align: center;
}

Ajustes de tema

A dica só tem algumas cores para gerenciar, já que a cor do texto é herdada da página pela palavra-chave do sistema CanvasText. Além disso, como criamos propriedades personalizadas para armazenar os valores, podemos atualizar apenas essas propriedades e deixar o tema cuidar do resto:

@media (prefers-color-scheme: light) {
  tool-tip {
    --_bg: white;
    --_shadow-alpha: 15%;
  }
}

Uma captura de tela lado a lado das versões clara e escura da dica.

Para o tema claro, adaptamos o plano de fundo para branco e diminuímos a intensidade das sombras ajustando a opacidade.

Da direita para a esquerda

Para oferecer suporte a modos de leitura da direita para a esquerda, uma propriedade personalizada vai armazenar o valor da direção do documento em um valor de -1 ou 1, respectivamente.

tool-tip {
  --isRTL: -1;
}

tool-tip:dir(rtl) {
  --isRTL: 1;
}

Isso pode ser usado para ajudar a posicionar a dica:

tool-tip[tip-position="top"]) {
  --_x: calc(50% * var(--isRTL));
}

Além de ajudar a identificar onde o triângulo está:

tool-tip[tip-position="right"]::after {
  --_tip: var(--_left-tip);
}

tool-tip[tip-position="right"]:dir(rtl)::after {
  --_tip: var(--_right-tip);
}

Por fim, também pode ser usado para transformações lógicas em translateX():

--_x: calc(var(--isRTL) * -3px * -1);

Posicionamento da dica

Posicione a dica de ferramenta de forma lógica com as propriedades inset-block ou inset-inline para processar as posições física e lógica da dica. O código a seguir mostra como cada uma das quatro posições é estilizada para direções da esquerda para a direita e da direita para a esquerda.

Alinhamento superior e de início do bloco

Uma captura de tela mostrando a diferença de posicionamento entre a posição superior da esquerda para a direita e a posição superior da direita para a esquerda.

tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position])) {
  inset-inline-start: 50%;
  inset-block-end: calc(100% + var(--_p-block) + var(--_triangle-size));
  --_x: calc(50% * var(--isRTL));
}

tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position]))::after {
  --_tip: var(--_bottom-tip);
  inset-block-end: calc(var(--_triangle-size) * -1);
  border-block-end: var(--_triangle-size) solid transparent;
}

Alinhamento à direita e inline-end

Uma captura de tela mostrando a diferença de posicionamento entre a posição direita da esquerda para a direita e a posição inline-end da direita para a esquerda.

tool-tip:is([tip-position="right"], [tip-position="inline-end"]) {
  inset-inline-start: calc(100% + var(--_p-inline) + var(--_triangle-size));
  inset-block-end: 50%;
  --_y: 50%;
}

tool-tip:is([tip-position="right"], [tip-position="inline-end"])::after {
  --_tip: var(--_left-tip);
  inset-inline-start: calc(var(--_triangle-size) * -1);
  border-inline-start: var(--_triangle-size) solid transparent;
}

tool-tip:is([tip-position="right"], [tip-position="inline-end"]):dir(rtl)::after {
  --_tip: var(--_right-tip);
}

Alinhamento inferior e de fim de bloco

Uma captura de tela mostrando a diferença de posicionamento entre a posição inferior da esquerda para a direita e a posição final do bloco da direita para a esquerda.

tool-tip:is([tip-position="bottom"], [tip-position="block-end"]) {
  inset-inline-start: 50%;
  inset-block-start: calc(100% + var(--_p-block) + var(--_triangle-size));
  --_x: calc(50% * var(--isRTL));
}

tool-tip:is([tip-position="bottom"], [tip-position="block-end"])::after {
  --_tip: var(--_top-tip);
  inset-block-start: calc(var(--_triangle-size) * -1);
  border-block-start: var(--_triangle-size) solid transparent;
}

Alinhamento à esquerda e inline-start

Uma captura de tela mostrando a diferença de posicionamento entre a posição esquerda da esquerda para a direita e a posição inline-start da direita para a esquerda.

tool-tip:is([tip-position="left"], [tip-position="inline-start"]) {
  inset-inline-end: calc(100% + var(--_p-inline) + var(--_triangle-size));
  inset-block-end: 50%;
  --_y: 50%;
}

tool-tip:is([tip-position="left"], [tip-position="inline-start"])::after {
  --_tip: var(--_right-tip);
  inset-inline-end: calc(var(--_triangle-size) * -1);
  border-inline-end: var(--_triangle-size) solid transparent;
}

tool-tip:is([tip-position="left"], [tip-position="inline-start"]):dir(rtl)::after {
  --_tip: var(--_left-tip);
}

Animação

Até agora, só alternamos a visibilidade da dica. Nesta seção, vamos primeiro animar a opacidade para todos os usuários, já que é uma transição de movimento reduzido geralmente segura. Em seguida, vamos animar a posição da transformação para que a dica apareça deslizando para fora do elemento pai.

Uma transição padrão segura e significativa

Estilize o elemento da dica para fazer a transição de opacidade e transformação, assim:

tool-tip {
  opacity: 0;
  transform: translateX(var(--_x, 0)) translateY(var(--_y, 0));
  transition: opacity .2s ease, transform .2s ease;
}

:has(> tool-tip):is(:hover, :focus-visible, :active) > tool-tip {
  opacity: 1;
  transition-delay: 200ms;
}

Adicionar movimento à transição

Para cada um dos lados em que uma dica pode aparecer, se o usuário não se importar com o movimento, posicione levemente a propriedade translateX, a ela uma pequena distância para percorrer:

@media (prefers-reduced-motion: no-preference) {
  :has(> tool-tip:is([tip-position="top"], [tip-position="block-start"], :not([tip-position]))):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_y: 3px;
  }

  :has(> tool-tip:is([tip-position="right"], [tip-position="inline-end"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_x: -3px;
  }

  :has(> tool-tip:is([tip-position="bottom"], [tip-position="block-end"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_y: -3px;
  }

  :has(> tool-tip:is([tip-position="left"], [tip-position="inline-start"])):not(:hover):not(:focus-visible):not(:active) tool-tip {
    --_x: 3px;
  }
}

Observe que isso está definindo o estado "out", já que o estado "in" está em translateX(0).

JavaScript

Na minha opinião, o JavaScript é opcional. Isso porque nenhuma dessas dicas precisa ser lida para realizar uma tarefa na interface. Portanto, se as dicas falharem completamente, não será um grande problema. Isso também significa que podemos tratar as dicas como aprimoradas progressivamente. Eventualmente, todos os navegadores vão oferecer suporte a :has(), e esse script poderá ser totalmente removido.

O script polyfill faz duas coisas, e só faz isso se o navegador não oferecer suporte a :has(). Primeiro, verifique se há suporte para :has():

if (!CSS.supports('selector(:has(*))')) {
  // do work
}

Em seguida, encontre os elementos pai dos <tool-tip>s e atribua a eles um nome de classe para trabalhar:

if (!CSS.supports('selector(:has(*))')) {
  document.querySelectorAll('tool-tip').forEach(tooltip =>
    tooltip.parentNode.classList.add('has_tool-tip'))
}

Em seguida, injete um conjunto de estilos que usam esse nome de classe, simulando o seletor :has() para o mesmo comportamento:

if (!CSS.supports('selector(:has(*))')) {
  document.querySelectorAll('tool-tip').forEach(tooltip =>
    tooltip.parentNode.classList.add('has_tool-tip'))

  let styles = document.createElement('style')
  styles.textContent = `
    .has_tool-tip {
      position: relative;
    }
    .has_tool-tip:is(:hover, :focus-visible, :active) > tool-tip {
      opacity: 1;
      transition-delay: 200ms;
    }
  `
  document.head.appendChild(styles)
}

Pronto! Agora todos os navegadores vão mostrar as dicas se :has() não for compatível.

Conclusão

Agora que você sabe como eu fiz isso, como você faria? 🙂 Estou muito ansioso para usar a API popup para facilitar as dicas de alternância, a camada superior para evitar conflitos de z-index e a API anchor para posicionar melhor os elementos na janela. Até lá, vou criar dicas.

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web.

Crie uma demonstração, me envie um tweet com o link, e eu vou adicionar à seção de remixes da comunidade abaixo.

Remixes da comunidade

Ainda não há nada aqui.

Recursos