Como criar um componente de configurações

Uma visão geral básica de como criar um componente de configurações de controles deslizantes e caixas de seleção.

Nesta postagem, quero compartilhar ideias sobre como criar um componente de configurações para a Web que seja responsivo, ofereça suporte a várias entradas de dispositivo e funcione em vários navegadores. Teste a demonstração.

Demonstração

Se você prefere vídeos ou quer uma prévia da IU/UX do que estamos criando, confira este tutorial mais curto no YouTube:

Visão geral

Dividimos os aspectos desse componente nas seguintes seções:

  1. Layouts
  2. Cor
  3. Entrada de intervalo personalizado
  4. Entrada de caixa de seleção personalizada
  5. Considerações sobre acessibilidade
  6. JavaScript

Layouts

Esta é a primeira demonstração do Desafio da GUI que é totalmente em grade CSS. Confira cada grade destacada com o Chrome DevTools para grade:

Contornos coloridos e sobreposições de espaçamento que ajudam a mostrar todas as caixas que compõem o layout das configurações

Just for gap

O layout mais comum:

foo {
  display: grid;
  gap: var(--something);
}

Eu chamo esse layout de "apenas para espaçamento" porque ele só usa a grade para adicionar espaçamentos entre os blocos.

Cinco layouts usam essa estratégia, todos mostrados aqui:

Layouts de grade vertical destacados com contornos e lacunas preenchidas

O elemento fieldset, que contém cada grupo de entrada (.fieldset-item), está usando gap: 1px para criar as bordas finas entre os elementos. Não há uma solução de fronteira complicada.

Lacuna preenchida
.grid {
  display: grid;
  gap: 1px;
  background: var(--bg-surface-1);

  & > .fieldset-item {
    background: var(--bg-surface-2);
  }
}
Truque de borda
.grid {
  display: grid;

  & > .fieldset-item {
    background: var(--bg-surface-2);

    &:not(:last-child) {
      border-bottom: 1px solid var(--bg-surface-1);
    }
  }
}

Quebra de grade natural

O layout mais complexo acabou sendo o macro, o sistema de layout lógico entre <main> e <form>.

Como centralizar o conteúdo agrupado

O Flexbox e o Grid oferecem recursos para align-items ou align-content. Ao lidar com elementos de quebra de linha, os alinhamentos de layout content distribuem o espaço entre os filhos como um grupo.

main {
  display: grid;
  gap: var(--space-xl);
  place-content: center;
}

O elemento principal está usando a abreviação de alinhamento place-content: center para que os filhos sejam centralizados vertical e horizontalmente em layouts de uma e duas colunas.

Assista ao vídeo acima para ver como o "conteúdo" permanece centralizado, mesmo que o agrupamento tenha ocorrido.

Repetir minmax de ajuste automático

O <form> usa um layout de grade adaptável para cada seção. Esse layout alterna entre uma e duas colunas com base no espaço disponível.

form {
  display: grid;
  gap: var(--space-xl) var(--space-xxl);
  grid-template-columns: repeat(auto-fit, minmax(min(10ch, 100%), 35ch));
  align-items: flex-start;
  max-width: 89vw;
}

Essa grade tem um valor diferente para row-gap (--space-xl) em relação a column-gap (--space-xxl) para dar um toque personalizado ao layout responsivo. Quando as colunas se empilham, queremos um espaço grande, mas não tão grande quanto em uma tela ampla.

A propriedade grid-template-columns usa três funções CSS: repeat(), minmax() e min(). Una Kravets tem uma ótima postagem de blog sobre layout sobre isso, chamando-a de RAM.

Há três adições especiais no nosso layout, se compararmos com o da Una:

  • Passamos uma função min() extra.
  • Especificamos align-items: flex-start.
  • Há um estilo max-width: 89vw.

A função extra min() é bem descrita por Evan Minto no blog dele, na postagem Intrinsically Responsive CSS Grid with minmax() and min() (em inglês). Recomendo a leitura. A correção de alinhamento flex-start é para remover o efeito de alongamento padrão, para que os filhos desse layout não precisem ter alturas iguais, mas sim alturas naturais e intrínsecas. O vídeo do YouTube tem um resumo rápido dessa adição de alinhamento.

max-width: 89vw vale uma pequena análise neste post. Veja o layout com e sem o estilo aplicado:

O que está acontecendo? Quando max-width é especificado, ele fornece contexto, dimensionamento explícito ou dimensionamento definido para que o algoritmo de layout auto-fit saiba quantas repetições podem caber no espaço. Embora pareça óbvio que o espaço é de "largura total", de acordo com a especificação de grade CSS, um tamanho ou tamanho máximo definido precisa ser fornecido. Forneci um tamanho máximo.

Então, por que 89vw? Porque "funcionou" para o meu layout. Eu e outras pessoas do Chrome estamos investigando por que um valor mais razoável, como 100vw, não é suficiente e se isso é de fato um bug.

Espaçamento

A maior parte da harmonia desse layout vem de uma paleta limitada de espaçamento, 7 para ser exato.

:root {
  --space-xxs: .25rem;
  --space-xs:  .5rem;
  --space-sm:  1rem;
  --space-md:  1.5rem;
  --space-lg:  2rem;
  --space-xl:  3rem;
  --space-xxl: 6rem;
}

O uso deles funciona muito bem com grade, CSS @nest e sintaxe de nível 5 de @media. Veja um exemplo: o conjunto de estilos de layout totalmente <main>.

main {
  display: grid;
  gap: var(--space-xl);
  place-content: center;
  padding: var(--space-sm);

  @media (width >= 540px) {
    & {
      padding: var(--space-lg);
    }
  }

  @media (width >= 800px) {
    & {
      padding: var(--space-xl);
    }
  }
}

Uma grade com conteúdo centralizado, com preenchimento moderado por padrão (como em dispositivos móveis). No entanto, à medida que mais espaço de janela de visualização fica disponível, ele se espalha aumentando o padding. O CSS de 2021 está muito bom!

Lembra do layout anterior, "apenas para lacunas"? Veja uma versão mais completa de como eles aparecem neste componente:

header {
  display: grid;
  gap: var(--space-xxs);
}

section {
  display: grid;
  gap: var(--space-md);
}

Cor

O uso controlado de cores ajudou esse design a se destacar como expressivo, mas minimalista. Eu faço assim:

:root {
  --surface1: lch(10 0 0);
  --surface2: lch(15 0 0);
  --surface3: lch(20 0 0);
  --surface4: lch(25 0 0);

  --text1: lch(95 0 0);
  --text2: lch(75 0 0);
}

Eu nomeio as cores da superfície e do texto com números, em vez de nomes como surface-dark e surface-darker, porque em uma consulta de mídia, eu as inverto, e claro e escuro não fazem sentido.

Eu as inverto em uma consulta de mídia de preferência como esta:

:root {
  ...

  @media (prefers-color-scheme: light) {
    & {
      --surface1: lch(90 0 0);
      --surface2: lch(100 0 0);
      --surface3: lch(98 0 0);
      --surface4: lch(85 0 0);

      --text1: lch(20 0 0);
      --text2: lch(40 0 0);
    }
  }
}

É importante ter uma visão geral da estratégia antes de entrar nos detalhes da sintaxe de cores. Mas, como me adiantei um pouco, vou voltar um pouco.

LCH?

Sem entrar muito na teoria das cores, o LCH é uma sintaxe voltada para humanos, que atende à forma como percebemos as cores, não como as medimos com matemática (como 255). Isso dá uma vantagem distinta, já que os humanos podem escrever com mais facilidade e outros humanos vão estar em sintonia com esses ajustes.

Uma captura de tela da página da Web pod.link/csspodcast, com o episódio &quot;Color 2: Perception&quot; aberto
Aprenda mais sobre cores perceptivas (e muito mais!) no podcast de CSS

Hoje, nesta demonstração, vamos focar na sintaxe e nos valores que estou alternando para criar os modos claro e escuro. Vamos analisar uma cor de superfície e uma de texto:

:root {
  --surface1: lch(10 0 0);
  --text1:    lch(95 0 0);

  @media (prefers-color-scheme: light) {
    & {
      --surface1: lch(90 0 0);
      --text1:    lch(40 0 0);
    }
  }
}

--surface1: lch(10 0 0) se traduz em 10% de luminosidade, 0 de croma e 0 de matiz: um cinza muito escuro e sem cor. Em seguida, na consulta de mídia para o modo claro, a luminosidade é invertida para 90% com --surface1: lch(90 0 0);. E essa é a essência da estratégia. Comece mudando apenas a luminosidade entre os dois temas, mantendo as proporções de contraste exigidas pelo design ou o que pode manter a acessibilidade.

O bônus com lch() aqui é que a luminosidade é orientada para humanos, e podemos nos sentir bem com uma mudança de % nela, que será perceptível e consistente com essa diferença de %. hsl(), por exemplo, não é tão confiável.

Há mais a aprender sobre espaços de cores e lch(), se você tiver interesse. Está chegando!

No momento, o CSS não consegue acessar essas cores. Vou repetir: não temos acesso a um terço das cores na maioria dos monitores modernos. E não são cores quaisquer, mas as cores mais vívidas que a tela pode exibir. Nossos sites estão desbotados porque o hardware do monitor evoluiu mais rápido do que as especificações de CSS e as implementações do navegador.

Lea Verou

Controles de formulário adaptáveis com esquema de cores

Muitos navegadores oferecem controles de tema escuro, como o Safari e o Chromium, mas você precisa especificar no CSS ou HTML que seu design os utiliza.

O exemplo acima demonstra o efeito da propriedade no painel "Estilos" do DevTools. A demonstração usa a tag HTML, que, na minha opinião, geralmente é um local melhor:

<meta name="color-scheme" content="dark light">

Saiba tudo sobre isso neste artigo do color-scheme (em inglês) escrito por Thomas Steiner (em inglês). Há muito mais a ganhar do que entradas de caixa de seleção escuras.

CSS accent-color

Houve atividades recentes em torno de accent-color em elementos de formulário, sendo um único estilo CSS que pode mudar a cor de tonalidade usada no elemento de entrada dos navegadores. Leia mais sobre isso aqui no GitHub. Eu o incluí nos meus estilos para esse componente. Como os navegadores são compatíveis, as caixas de seleção vão ficar mais no tema com os destaques de cor rosa e roxo.

input[type="checkbox"] {
  accent-color: var(--brand);
}

Captura de tela do Chromium no Linux com caixas de seleção cor-de-rosa

Destaques de cor com gradientes fixos e foco interno

As cores se destacam mais quando usadas com moderação, e uma das maneiras de conseguir isso é com interações coloridas na interface.

Há muitas camadas de feedback e interação da interface no vídeo acima, que ajudam a dar personalidade à interação:

  • Destaque de contexto.
  • Fornecer feedback da IU sobre "quão cheio" o valor está no intervalo.
  • Fornecer feedback da interface de que um campo está aceitando entrada.

Para fornecer feedback quando um elemento está sendo interagido, o CSS usa a pseudoclasse :focus-within para mudar a aparência de vários elementos. Vamos analisar o .fieldset-item, que é superinteressante:

.fieldset-item {
  ...

  &:focus-within {
    background: var(--surface2);

    & svg {
      fill: white;
    }

    & picture {
      clip-path: circle(50%);
      background: var(--brand-bg-gradient) fixed;
    }
  }
}

Quando um dos filhos desse elemento tem focus-within:

  1. O plano de fundo .fieldset-item recebe uma cor de superfície de maior contraste.
  2. O svg aninhado é preenchido em branco para aumentar o contraste.
  3. O <picture> clip-path aninhado se expande para um círculo completo, e o plano de fundo é preenchido com o gradiente fixo brilhante.

Intervalo personalizado

Considerando o elemento de entrada HTML a seguir, vou mostrar como personalizei a aparência dele:

<input type="range">

Há três partes desse elemento que precisamos personalizar:

  1. Elemento / contêiner de intervalo
  2. Acompanhamento
  3. Polegar

Estilos de elementos de intervalo

input[type="range"] {
  /* style setting variables */
  --track-height: .5ex;
  --track-fill: 0%;
  --thumb-size: 3ex;
  --thumb-offset: -1.25ex;
  --thumb-highlight-size: 0px;

  appearance: none;         /* clear styles, make way for mine */
  display: block;
  inline-size: 100%;        /* fill container */
  margin: 1ex 0;            /* ensure thumb isn't colliding with sibling content */
  background: transparent;  /* bg is in the track */
  outline-offset: 5px;      /* focus styles have space */
}

As primeiras linhas de CSS são as partes personalizadas dos estilos, e espero que rotulá-las claramente ajude. O restante dos estilos são, em grande parte, estilos de redefinição, para fornecer uma base consistente para criar as partes complicadas do componente.

Estilos de faixa

input[type="range"]::-webkit-slider-runnable-track {
  appearance: none; /* clear styles, make way for mine */
  block-size: var(--track-height);
  border-radius: 5ex;
  background:
    /* hard stop gradient:
        - half transparent (where colorful fill we be)
        - half dark track fill
        - 1st background image is on top
    */
    linear-gradient(
      to right,
      transparent var(--track-fill),
      var(--surface1) 0%
    ),
    /* colorful fill effect, behind track surface fill */
    var(--brand-bg-gradient) fixed;
}

O truque é "revelar" a cor de preenchimento vibrante. Isso é feito com o gradiente de parada brusca na parte de cima. O gradiente é transparente até a porcentagem de preenchimento e, depois disso, usa a cor da superfície da faixa não preenchida. Atrás dessa superfície não preenchida, há uma cor de largura total, esperando que a transparência a revele.

Estilo de preenchimento da faixa

Meu design precisa de JavaScript para manter o estilo de preenchimento. Há estratégias somente CSS, mas elas exigem que o elemento de controle tenha a mesma altura da faixa, e não consegui encontrar uma harmonia dentro desses limites.

/* grab sliders on page */
const sliders = document.querySelectorAll('input[type="range"]')

/* take a slider element, return a percentage string for use in CSS */
const rangeToPercent = slider => {
  const max = slider.getAttribute('max') || 10;
  const percent = slider.value / max * 100;

  return `${parseInt(percent)}%`;
};

/* on page load, set the fill amount */
sliders.forEach(slider => {
  slider.style.setProperty('--track-fill', rangeToPercent(slider));

  /* when a slider changes, update the fill prop */
  slider.addEventListener('input', e => {
    e.target.style.setProperty('--track-fill', rangeToPercent(e.target));
  })
})

Acho que isso é um bom upgrade visual. O controle deslizante funciona muito bem sem JavaScript, a propriedade --track-fill não é obrigatória, ele simplesmente não terá um estilo de preenchimento se não estiver presente. Se o JavaScript estiver disponível, preencha a propriedade personalizada e observe as mudanças do usuário, sincronizando a propriedade personalizada com o valor.

Confira esta ótima postagem no CSS-Tricks (em inglês) de Ana Tudor, que demonstra uma solução somente CSS para preenchimento de faixa. Também achei este elemento range muito inspirador.

Estilos de miniatura

input[type="range"]::-webkit-slider-thumb {
  appearance: none; /* clear styles, make way for mine */
  cursor: ew-resize; /* cursor style to support drag direction */
  border: 3px solid var(--surface3);
  block-size: var(--thumb-size);
  inline-size: var(--thumb-size);
  margin-top: var(--thumb-offset);
  border-radius: 50%;
  background: var(--brand-bg-gradient) fixed;
}

A maioria desses estilos serve para criar um círculo bonito. Novamente, você vê o gradiente de fundo fixo que unifica as cores dinâmicas dos controles deslizantes, das faixas e dos elementos SVG associados. Separei os estilos da interação para ajudar a isolar a técnica box-shadow usada para o destaque ao passar o cursor:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

::-webkit-slider-thumb {
  …

  /* shadow spread is initally 0 */
  box-shadow: 0 0 0 var(--thumb-highlight-size) var(--thumb-highlight-color);

  /* if motion is OK, transition the box-shadow change */
  @media (--motionOK) {
    & {
      transition: box-shadow .1s ease;
    }
  }

  /* on hover/active state of parent, increase size prop */
  @nest input[type="range"]:is(:hover,:active) & {
    --thumb-highlight-size: 10px;
  }
}

O objetivo era criar um destaque visual animado e fácil de gerenciar para o feedback do usuário. Ao usar uma sombra de caixa, posso evitar acionar o layout com o efeito. Para isso, crio uma sombra que não é desfocada e corresponde ao formato circular do elemento de polegar. Em seguida, mudo e faço a transição do tamanho da propagação ao passar o cursor.

Se o efeito de destaque fosse tão fácil nas caixas de seleção…

Seletores entre navegadores

Descobri que precisava desses seletores -webkit- e -moz- para alcançar consistência entre navegadores:

input[type="range"] {
  &::-webkit-slider-runnable-track {}
  &::-moz-range-track {}
  &::-webkit-slider-thumb {}
  &::-moz-range-thumb {}
}

Caixa de seleção personalizada

Considerando o elemento de entrada HTML a seguir, vou mostrar como personalizei a aparência dele:

<input type="checkbox">

Há três partes desse elemento que precisamos personalizar:

  1. Elemento de caixa de seleção
  2. Rótulos associados
  3. Efeito de destaque

Elemento de caixa de seleção

input[type="checkbox"] {
  inline-size: var(--space-sm);   /* increase width */
  block-size: var(--space-sm);    /* increase height */
  outline-offset: 5px;            /* focus style enhancement */
  accent-color: var(--brand);     /* tint the input */
  position: relative;             /* prepare for an absolute pseudo element */
  transform-style: preserve-3d;   /* create a 3d z-space stacking context */
  margin: 0;
  cursor: pointer;
}

Os estilos transform-style e position preparam o pseudoelemento que vamos introduzir mais tarde para estilizar o destaque. Caso contrário, são apenas algumas pequenas opiniões minhas sobre estilo. Gosto que o cursor seja um ponteiro, gosto de deslocamentos de contorno, as caixas de seleção padrão são muito pequenas e, se accent-color for compatível, traga essas caixas de seleção para o esquema de cores da marca.

Rótulos de caixas de seleção

É importante fornecer rótulos para caixas de seleção por dois motivos. A primeira é representar para que o valor da caixa de seleção é usado, para responder "ativado ou desativado para quê?" O segundo é para UX, os usuários da Web se acostumaram a interagir com caixas de seleção usando os rótulos associados.

entrada
<input
  type="checkbox"
  id="text-notifications"
  name="text-notifications"
>
o rótulo.
<label for="text-notifications">
  <h3>Text Messages</h3>
  <small>Get notified about all text messages sent to your device</small>
</label>

No rótulo, coloque um atributo for que aponte para uma caixa de seleção por ID: <label for="text-notifications">. Na caixa de seleção, duplique o nome e o ID para garantir que ela seja encontrada com várias ferramentas e tecnologias, como um mouse ou leitor de tela: <input type="checkbox" id="text-notifications" name="text-notifications">. :hover, :active e outros recursos são oferecidos sem custo financeiro com a conexão, aumentando as formas de interação com o formulário.

Destaque da caixa de seleção

Quero manter minhas interfaces consistentes, e o elemento deslizante tem um destaque de miniatura legal que eu gostaria de usar com a caixa de seleção. A miniatura conseguiu usar box-shadow e a propriedade spread para aumentar e diminuir uma sombra. No entanto, esse efeito não funciona aqui porque as caixas de seleção são, e devem ser, quadradas.

Consegui o mesmo efeito visual com um pseudoelemento e uma quantidade infeliz de CSS complicado:

@custom-media --motionOK (prefers-reduced-motion: no-preference);

input[type="checkbox"]::before {
  --thumb-scale: .01;                        /* initial scale of highlight */
  --thumb-highlight-size: var(--space-xl);

  content: "";
  inline-size: var(--thumb-highlight-size);
  block-size: var(--thumb-highlight-size);
  clip-path: circle(50%);                     /* circle shape */
  position: absolute;                         /* this is why position relative on parent */
  top: 50%;                                   /* pop and plop technique (https://web.dev/centering-in-css#5-pop-and-plop) */
  left: 50%;
  background: var(--thumb-highlight-color);
  transform-origin: center center;            /* goal is a centered scaling circle */
  transform:                                  /* order here matters!! */
    translateX(-50%)                          /* counter balances left: 50% */
    translateY(-50%)                          /* counter balances top: 50% */
    translateZ(-1px)                          /* PUTS IT BEHIND THE CHECKBOX */
    scale(var(--thumb-scale))                 /* value we toggle for animation */
  ;
  will-change: transform;

  @media (--motionOK) {                       /* transition only if motion is OK */
    & {
      transition: transform .2s ease;
    }
  }
}

/* on hover, set scale custom property to "in" state */
input[type="checkbox"]:hover::before {
  --thumb-scale: 1;
}

Criar um pseudoelemento de círculo é um trabalho simples, mas colocá-lo atrás do elemento ao qual ele está anexado foi mais difícil. Veja o antes e o depois da correção:

É uma microinteração, mas é importante para mim manter a consistência visual. A técnica de dimensionamento de animação é a mesma que usamos em outros lugares. Definimos uma propriedade personalizada com um novo valor e deixamos que o CSS faça a transição com base nas preferências de movimento. O recurso principal aqui é translateZ(-1px). O pai criou um espaço 3D, e esse filho de pseudoelemento o acessou colocando-se um pouco para trás no espaço Z.

Acessibilidade

O vídeo do YouTube faz uma ótima demonstração das interações de mouse, teclado e leitor de tela para esse componente de configurações. Vou destacar alguns detalhes aqui.

Opções de elementos HTML

<form>
<header>
<fieldset>
<picture>
<label>
<input>

Cada um deles contém dicas para a ferramenta de navegação do usuário. Alguns elementos fornecem dicas de interação, outros conectam a interatividade e outros ajudam a moldar a árvore de acessibilidade que um leitor de tela navega.

Atributos HTML

Podemos ocultar elementos que não são necessários para leitores de tela, neste caso, o ícone ao lado do controle deslizante:

<picture aria-hidden="true">

O vídeo acima demonstra o fluxo do leitor de tela no Mac OS. Observe como o foco de entrada passa diretamente de um controle deslizante para o outro. Isso ocorre porque ocultamos o ícone que pode ter sido uma parada no caminho para o próximo controle deslizante. Sem esse atributo, o usuário precisaria parar, ouvir e passar pela imagem que talvez não consiga ver.

O SVG é um monte de matemática. Vamos adicionar um elemento <title> para um título de passagem do mouse e um comentário legível por humanos sobre o que a matemática está criando:

<svg viewBox="0 0 24 24">
  <title>A note icon</title>
  <path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/>
</svg>

Além disso, usamos HTML suficiente claramente marcado para que o formulário seja testado muito bem com mouse, teclado, controles de videogame e leitores de tela.

JavaScript

Já abordamos como a cor de preenchimento da faixa estava sendo gerenciada pelo JavaScript. Agora, vamos analisar o JavaScript relacionado a <form>:

const form = document.querySelector('form');

form.addEventListener('input', event => {
  const formData = Object.fromEntries(new FormData(form));
  console.table(formData);
})

Sempre que o formulário é interagido e alterado, o console registra o formulário como um objeto em uma tabela para facilitar a revisão antes de enviar para um servidor.

Uma captura de tela dos resultados de console.table(), em que os dados do formulário são mostrados em uma tabela

Conclusão

Agora que você sabe como eu fiz, como você faria?! Isso torna a arquitetura de componentes bem divertida. Quem vai criar a primeira versão com slots no framework favorito? 🙂

Vamos diversificar nossas abordagens e aprender todas as maneiras de criar na Web. Crie uma demonstração, envie um tweet com os links, e eu vou adicionar à seção Remixes da comunidade abaixo.

Remixes da comunidade

  • @tomayac com o estilo dele em relação à área de passagem do cursor para os rótulos das caixas de seleção. Esta versão não tem lacuna de passagem entre elementos: demonstração e fonte.