Como criar um componente de botão de divisão

Uma visão geral básica de como criar um componente de botão dividido acessível.

Nesta postagem, quero compartilhar uma maneira de criar um botão dividido . Teste a demonstração.

Demonstração

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

Visão geral

Os botões divididos são botões que ocultam um botão principal e uma lista de botões adicionais. Eles são úteis para expor uma ação comum e aninhar ações secundárias e menos usadas até que sejam necessárias. Um botão dividido pode ser crucial para ajudar um design ocupado a parecer minimalista. Um botão dividido avançado pode até lembrar a última ação do usuário e promovê-la à posição principal.

Um botão de divisão comum pode ser encontrado no seu aplicativo de e-mail. A ação principal é enviar, mas talvez você possa enviar depois ou salvar um rascunho:

Exemplo de botão dividido em um aplicativo de e-mail.

A área de ação compartilhada é boa, já que o usuário não precisa procurar. Eles sabem que as ações essenciais de e-mail estão contidas no botão dividido.

Peças

Vamos detalhar as partes essenciais de um botão dividido antes de discutir a orquestração geral e a experiência final do usuário. A ferramenta de inspeção de acessibilidade do VisBug é usada aqui para mostrar uma visão macro do componente, revelando aspectos do HTML, do estilo e da acessibilidade de cada parte principal.

Os elementos HTML que compõem o botão dividido.

Contêiner do botão dividido de nível superior

O componente de nível mais alto é um flexbox inline, com uma classe de gui-split-button, que contém a ação principal e o .gui-popup-button.

A classe gui-split-button inspecionada e mostrando as propriedades CSS usadas nessa classe.

O botão de ação principal

O <button> inicialmente visível e focalizável se encaixa no contêiner com dois formatos de canto correspondentes para interações de foco, passar o cursor e ativa, que aparecem contidas em .gui-split-button.

O inspetor mostrando as regras de CSS para o elemento de botão.

O botão de ativação do pop-up

O elemento de suporte "botão pop-up" é para ativar e fazer alusão à lista de botões secundários. Observe que não é um <button> e não é possível focar nele. No entanto, ele é a âncora de posicionamento para .gui-popup e o host para :focus-within usado para apresentar o pop-up.

O inspetor mostrando as regras de CSS para a classe gui-popup-button.

O card pop-up

Este é um card flutuante filho da âncora .gui-popup-button, posicionado de forma absoluta e encapsulando semanticamente a lista de botões.

O inspetor mostrando as regras de CSS para a classe gui-popup

As ações secundárias

Um <button> com foco e um tamanho de fonte um pouco menor do que o botão de ação principal tem um ícone e um estilo complementar ao botão principal.

O inspetor mostrando as regras de CSS para o elemento de botão.

Propriedades personalizadas

As variáveis a seguir ajudam a criar uma harmonia de cores e um local central para modificar valores usados em todo o componente.

@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --light (prefers-color-scheme: light);

.gui-split-button {
  --theme:             hsl(220 75% 50%);
  --theme-hover:  hsl(220 75% 45%);
  --theme-active:  hsl(220 75% 40%);
  --theme-text:      hsl(220 75% 25%);
  --theme-border: hsl(220 50% 75%);
  --ontheme:         hsl(220 90% 98%);
  --popupbg:         hsl(220 0% 100%);

  --border: 1px solid var(--theme-border);
  --radius: 6px;
  --in-speed: 50ms;
  --out-speed: 300ms;

  @media (--dark) {
    --theme:             hsl(220 50% 60%);
    --theme-hover:  hsl(220 50% 65%);
    --theme-active:  hsl(220 75% 70%);
    --theme-text:      hsl(220 10% 85%);
    --theme-border: hsl(220 20% 70%);
    --ontheme:         hsl(220 90% 5%);
    --popupbg:         hsl(220 10% 30%);
  }
}

Layouts e cores

Marcação

O elemento começa como um <div> com um nome de classe personalizado.

<div class="gui-split-button"></div>

Adicione o botão principal e os elementos .gui-popup-button.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions"></span>
</div>

Observe os atributos aria aria-haspopup e aria-expanded. Esses indicadores são essenciais para que os leitores de tela conheçam a capacidade e o estado da experiência do botão dividido. O atributo title é útil para todos.

Adicione um ícone <svg> e o elemento contêiner .gui-popup.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup"></ul>
  </span>
</div>

Para um posicionamento simples do pop-up, .gui-popup é um elemento filho do botão que o expande. A única desvantagem dessa estratégia é que o contêiner .gui-split-button não pode usar overflow: hidden, porque isso impede que o pop-up apareça visualmente.

Um <ul> preenchido com conteúdo <li><button> se anuncia como uma "lista de botões" para leitores de tela, que é exatamente a interface apresentada.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup">
      <li>
        <button>Schedule for later</button>
      </li>
      <li>
        <button>Delete</button>
      </li>
      <li>
        <button>Save draft</button>
      </li>
    </ul>
  </span>
</div>

Para dar um toque especial e usar cores de forma divertida, adicionei ícones aos botões secundários em https://heroicons.com. Os ícones são opcionais para os botões primários e secundários.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
    </svg>
    <ul class="gui-popup">
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
        </svg>
        Schedule for later
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
        </svg>
        Delete
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
        </svg>
        Save draft
      </button></li>
    </ul>
  </span>
</div>

Estilos

Com o HTML e o conteúdo no lugar, os estilos estão prontos para fornecer cor e layout.

Como estilizar o contêiner do botão dividido

Um tipo de exibição inline-flex funciona bem para esse componente de encapsulamento, já que ele precisa se ajustar em linha com outros botões divididos, ações ou elementos.

.gui-split-button {
  display: inline-flex;
  border-radius: var(--radius);
  background: var(--theme);
  color: var(--ontheme);
  fill: var(--ontheme);

  touch-action: manipulation;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

O botão dividido.

O estilo <button>

Os botões são muito bons para disfarçar a quantidade de código necessária. Talvez seja necessário desfazer ou substituir os estilos padrão do navegador, mas também será necessário aplicar alguma herança, adicionar estados de interação e se adaptar a várias preferências do usuário e tipos de entrada. Os estilos de botão se acumulam rapidamente.

Esses botões são diferentes dos normais porque compartilham um plano de fundo com um elemento pai. Normalmente, um botão tem a própria cor de plano de fundo e de texto. No entanto, eles compartilham e aplicam apenas o próprio plano de fundo na interação.

.gui-split-button button {
  cursor: pointer;
  appearance: none;
  background: none;
  border: none;

  display: inline-flex;
  align-items: center;
  gap: 1ch;
  white-space: nowrap;

  font-family: inherit;
  font-size: inherit;
  font-weight: 500;

  padding-block: 1.25ch;
  padding-inline: 2.5ch;

  color: var(--ontheme);
  outline-color: var(--theme);
  outline-offset: -5px;
}

Adicione estados de interação com algumas pseudoclasses do CSS e use propriedades personalizadas correspondentes para o estado:

.gui-split-button button {
  

  &:is(:hover, :focus-visible) {
    background: var(--theme-hover);
    color: var(--ontheme);

    & > svg {
      stroke: currentColor;
      fill: none;
    }
  }

  &:active {
    background: var(--theme-active);
  }
}

O botão principal precisa de alguns estilos especiais para concluir o efeito de design:

.gui-split-button > button {
  border-end-start-radius: var(--radius);
  border-start-start-radius: var(--radius);

  & > svg {
    fill: none;
    stroke: var(--ontheme);
  }
}

Por fim, para dar um toque especial, o botão e o ícone do tema claro recebem uma sombra:

.gui-split-button {
  @media (--light) {
    & > button,
    & button:is(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--theme-active);
    }
    & > .gui-popup-button > svg,
    & button:is(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--theme-active));
    }
  }
}

Um ótimo botão presta atenção às microinterações e aos pequenos detalhes.

Uma observação sobre :focus-visible

Observe como os estilos de botão usam :focus-visible em vez de :focus. :focus é um toque crucial para criar uma interface do usuário acessível, mas tem uma desvantagem: não é inteligente sobre se o usuário precisa ver ou não. Ele será aplicado a qualquer foco.

O vídeo abaixo tenta detalhar essa microinteração para mostrar como o :focus-visible é uma alternativa inteligente.

Estilizar o botão pop-up

Um flexbox 4ch para centralizar um ícone e ancorar uma lista de botões pop-up. Assim como o botão principal, ele é transparente até que seja passado o cursor ou interagido, e esticado para preencher.

A parte da seta do botão dividido usada para acionar o pop-up.

.gui-popup-button {
  inline-size: 4ch;
  cursor: pointer;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-inline-start: var(--border);
  border-start-end-radius: var(--radius);
  border-end-end-radius: var(--radius);
}

Adicione estados de passar o cursor, foco e ativo com CSS Nesting e o seletor funcional :is():

.gui-popup-button {
  

  &:is(:hover,:focus-within) {
    background: var(--theme-hover);
  }

  /* fixes iOS trying to be helpful */
  &:focus {
    outline: none;
  }

  &:active {
    background: var(--theme-active);
  }
}

Esses estilos são o principal gancho para mostrar e ocultar o pop-up. Quando o .gui-popup-button tiver focus em qualquer um dos filhos, defina opacity, posição e pointer-events no ícone e no pop-up.

.gui-popup-button {
  

  &:focus-within {
    & > svg {
      transition-duration: var(--in-speed);
      transform: rotateZ(.5turn);
    }
    & > .gui-popup {
      transition-duration: var(--in-speed);
      opacity: 1;
      transform: translateY(0);
      pointer-events: auto;
    }
  }
}

Com os estilos de entrada e saída concluídos, a última parte é fazer a transição condicional das transformações dependendo da preferência de movimento do usuário:

.gui-popup-button {
  

  @media (--motionOK) {
    & > svg {
      transition: transform var(--out-speed) ease;
    }
    & > .gui-popup {
      transform: translateY(5px);

      transition:
        opacity var(--out-speed) ease,
        transform var(--out-speed) ease;
    }
  }
}

Um olhar atento no código notaria que a opacidade ainda é transferida para usuários que preferem movimento reduzido.

Estilizar o pop-up

O elemento .gui-popup é uma lista de botões de card flutuante que usa propriedades personalizadas e unidades relativas para ser sutilmente menor, combinada de forma interativa com o botão principal e alinhada à marca com o uso de cores. Os ícones têm menos contraste, são mais finos e a sombra tem um toque de azul da marca. Assim como com botões, uma interface e uma experiência do usuário fortes são resultado desses pequenos detalhes.

Um elemento de card flutuante.

.gui-popup {
  --shadow: 220 70% 15%;
  --shadow-strength: 1%;

  opacity: 0;
  pointer-events: none;

  position: absolute;
  bottom: 80%;
  left: -1.5ch;

  list-style-type: none;
  background: var(--popupbg);
  color: var(--theme-text);
  padding-inline: 0;
  padding-block: .5ch;
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  font-size: .9em;
  transition: opacity var(--out-speed) ease;

  box-shadow:
    0 -2px 5px 0 hsl(var(--shadow) / calc(var(--shadow-strength) + 5%)),
    0 1px 1px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 10%)),
    0 2px 2px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 12%)),
    0 5px 5px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 13%)),
    0 9px 9px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 14%)),
    0 16px 16px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 20%))
  ;
}

Os ícones e botões recebem cores da marca para ter um estilo agradável em cada card com tema claro e escuro:

Links e ícones para finalização da compra, pagamento rápido e salvar para depois.

.gui-popup {
  

  & svg {
    fill: var(--popupbg);
    stroke: var(--theme);

    @media (prefers-color-scheme: dark) {
      stroke: var(--theme-border);
    }
  }

  & button {
    color: var(--theme-text);
    width: 100%;
  }
}

O pop-up do tema escuro tem adições de texto e sombra de ícone, além de uma sombra de caixa um pouco mais intensa:

O pop-up no tema escuro.

.gui-popup {
  

  @media (--dark) {
    --shadow-strength: 5%;
    --shadow: 220 3% 2%;

    & button:not(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--ontheme);
    }

    & button:not(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--ontheme));
    }
  }
}

Estilos de ícones genéricos <svg>

Todos os ícones são dimensionados em relação ao botão font-size em que são usados com a unidade ch como inline-size. Cada um também recebe alguns estilos para ajudar a delinear ícones suaves e uniformes.

.gui-split-button svg {
  inline-size: 2ch;
  box-sizing: content-box;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 2px;
}

Layout da direita para a esquerda

As propriedades lógicas fazem todo o trabalho complexo. Esta é a lista de propriedades lógicas usadas: - display: inline-flex cria um elemento flex inline. - padding-block e padding-inline como um par, em vez do atalho padding, para aproveitar os benefícios do padding nos lados lógicos. - border-end-start-radius e amigos vão arredondar cantos com base na direção do documento. - inline-size em vez de width garante que o tamanho não esteja vinculado a dimensões físicas. - border-inline-start adiciona uma borda ao início, que pode estar à direita ou à esquerda, dependendo da direção do script.

JavaScript

Quase todo o JavaScript a seguir é para melhorar a acessibilidade. Duas das minhas bibliotecas de ajuda são usadas para facilitar um pouco as tarefas. O BlingBlingJS é usado para consultas DOM concisas e configuração fácil de listeners de eventos, enquanto o roving-ux ajuda a facilitar interações acessíveis de teclado e gamepad para o pop-up.

import $ from 'blingblingjs'
import {rovingIndex} from 'roving-ux'

const splitButtons = $('.gui-split-button')
const popupButtons = $('.gui-popup-button')

Com as bibliotecas acima importadas e os elementos selecionados e salvos em variáveis, a atualização da experiência está a algumas funções de ser concluída.

Índice móvel

Quando um teclado ou leitor de tela foca o .gui-popup-button, queremos encaminhar o foco para o primeiro botão (ou o mais recentemente focado) no .gui-popup. A biblioteca ajuda a fazer isso com os parâmetros element e target.

popupButtons.forEach(element =>
  rovingIndex({
    element,
    target: 'button',
  }))

Agora, o elemento passa o foco para os filhos <button> de destino e ativa a navegação padrão com as teclas de seta para procurar opções.

Alternando aria-expanded

Embora seja visualmente aparente que um pop-up está aparecendo e desaparecendo, um leitor de tela precisa de mais do que pistas visuais. O JavaScript é usado aqui para complementar a interação :focus-within orientada por CSS alternando um atributo adequado para leitores de tela.

popupButtons.on('focusin', e => {
  e.currentTarget.setAttribute('aria-expanded', true)
})

popupButtons.on('focusout', e => {
  e.currentTarget.setAttribute('aria-expanded', false)
})

Como ativar a chave Escape

O foco do usuário foi enviado intencionalmente para uma armadilha, o que significa que precisamos oferecer uma maneira de sair. A maneira mais comum é permitir o uso da chave Escape. Para isso, observe as teclas pressionadas no botão pop-up, já que todos os eventos de teclado nos filhos vão subir para esse pai.

popupButtons.on('keyup', e => {
  if (e.code === 'Escape')
    e.target.blur()
})

Se o botão pop-up detectar qualquer pressionamento de tecla Escape, ele vai remover o foco de si mesmo com blur().

Cliques no botão de divisão

Por fim, se o usuário clicar, tocar ou interagir com os botões usando o teclado, o aplicativo precisará realizar a ação adequada. O bubbling de eventos é usado novamente aqui, mas desta vez no contêiner .gui-split-button, para capturar cliques de botão de um pop-up filho ou da ação principal.

splitButtons.on('click', event => {
  if (event.target.nodeName !== 'BUTTON') return
  console.info(event.target.innerText)
})

Conclusão

Agora que você sabe como eu fiz, como você faria? 🙂

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