Como criar um componente de configurações

Uma visão geral básica de como criar um componente de configurações com 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, compatível com várias entradas de dispositivos e funcione em todos os navegadores. Teste a demonstração.

Demonstração

Se você prefere vídeo ou quer uma prévia da interface/experiência do usuário do que estamos criando, confira um tutorial mais curto no YouTube:

Visão geral

Dividi 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 GUI Challenge a ser totalmente de grade CSS. Confira cada grade destacada com o Chrome DevTools para grade:

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

Apenas para lacuna

O layout mais comum:

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

Chamo esse layout de "apenas para lacuna" porque ele usa apenas a grade para adicionar lacunas entre os blocos.

Cinco layouts usam essa estratégia. Confira todos eles:

Layouts de grade vertical destacados com contornos e lacunas preenchidas

O elemento fieldset, que contém cada grupo de entrada (.fieldset-item), usa gap: 1px para criar as bordas finas entre os elementos. Sem solução de borda complicada!

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

  & > .fieldset-item {
    background: var(--bg-surface-2);
  }
}
Truque da 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>.

Centralizar conteúdo agrupado

O Flexbox e a grade oferecem recursos para align-items ou align-content, e ao lidar com elementos de ajuste de texto, 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 usa o atalho de alinhamento place-content: center para que os filhos sejam centralizados vertical e horizontalmente em layouts de uma e duas colunas.

Assista no vídeo acima como o "conteúdo" permanece centralizado, mesmo que o ajuste de texto tenha ocorrido.

Repetir minmax de ajuste automático

O <form> usa um layout de grade adaptável para cada seção. Esse layout muda de uma para 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) do que column-gap (--space-xxl) para dar um toque personalizado ao layout responsivo. Quando as colunas são empilhadas, 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 no blog sobre layouts (em inglês), chamando isso de RAM.

Há três adições especiais no nosso layout, se você comparar com o de Una:

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

A função min() extra é bem descrita por Evan Minto no blog dele na postagem Intrinsically Responsive CSS Grid with minmax() and min() (em inglês). Recomendo que você leia. A correção de alinhamento flex-start serve para remover o efeito de extensão padrão. Assim, os filhos desse layout não precisam ter alturas iguais, mas podem ter alturas naturais e intrínsecas. O vídeo do YouTube mostra rapidamente como adicionar esse alinhamento.

Vale a pena fazer uma pequena análise de max-width: 89vw nesta postagem. Vou mostrar 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 auto-fitalgoritmo de layout saiba quantas repetições podem caber no espaço. Embora pareça óbvio que o espaço é "largura total", de acordo com a especificação da grade CSS, um tamanho ou tamanho máximo definido precisa ser fornecido. Eu forneci um tamanho máximo.

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

Espaçamento

A maior parte da harmonia desse layout vem de uma paleta limitada de espaçamento, sete 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 desses fluxos funciona muito bem com a grade, o CSS @nest e a sintaxe de nível 5 de @media. Por 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, moderadamente preenchida por padrão (como em dispositivos móveis). Mas, à medida que mais espaço de viewport fica disponível, ele se espalha aumentando o padding. O CSS de 2021 está muito bom!

Você se lembra do layout anterior, "apenas para lacuna"? Confira 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. 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);
}

Nomeio minhas cores de superfície e texto com números em vez de nomes como surface-dark e surface-darker porque, em uma consulta de mídia, vou inverter essas cores, e claro e escuro não terão significado.

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

: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 orientada ao ser humano, 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 se sintonizar com esses ajustes.

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

Por hoje, nesta demonstração, vamos focar na sintaxe e nos valores que estou alternando para criar o modo claro e escuro. Vamos analisar uma superfície e uma cor 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) é traduzido para 10% luminosidade, 0 croma e 0 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);. 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 o ser humano, e podemos nos sentir bem com uma mudança de % nela, que será perceptualmente e consistentemente % diferente. hsl(), por exemplo, não é tão confiável.

Se quiser saber mais sobre espaços de cores e lch(), clique aqui. Está chegando!

No momento, o CSS não pode acessar essas cores. Repetindo: não temos acesso a um terço das cores na maioria dos monitores modernos. E não são quaisquer cores, mas as mais vívidas que a tela pode mostrar. 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, atualmente o Safari e o Chromium, mas você precisa especificar em CSS ou HTML que seu design os usa.

O exemplo acima demonstra o efeito da propriedade no painel "Estilos" das 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 color-schemeartigo de Thomas Steiner. Há muito mais a ganhar do que entradas de caixa de seleção escuras!

CSS accent-color

Houve atividade recente em torno de accent-color em elementos de formulário, sendo um único estilo CSS que pode mudar a cor de matiz usada no elemento de entrada dos navegadores. Leia mais sobre isso aqui no GitHub. Eu o incluí nos meus estilos para esse componente. À medida que os navegadores forem compatíveis, minhas caixas de seleção vão ficar mais no tema com os toques de cor rosa e roxa.

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

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

Destaques de cor com gradientes fixos e foco interno

A cor se destaca mais quando é usada com moderação, e uma das maneiras que gosto de fazer isso é usando 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 do contexto.
  • Fornecer feedback da interface do usuário sobre "o quanto" o valor está no intervalo.
  • Fornecer feedback da interface informando 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. É muito interessante:

.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 foco em si:

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

Período personalizado

Dado o seguinte elemento de entrada HTML, 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. Thumb (link em inglês)

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 a rotulagem clara ajude. O restante dos estilos são principalmente estilos de redefinição, para fornecer uma base consistente para a criação das 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. Por trás dessa superfície não preenchida, há uma cor de largura total, esperando a transparência para revelá-la.

Estilo de preenchimento da faixa

Meu design precisa de JavaScript para manter o estilo de preenchimento. Existem estratégias somente de CSS, mas elas exigem que o elemento de polegar 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 melhora bastante a aparência. O controle deslizante funciona muito bem sem JavaScript. A propriedade --track-fill não é obrigatória, ela 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 um ótimo post no CSS-Tricks (em inglês) de Ana Tudor, que demonstra uma solução somente em 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 é para fazer um círculo bonito. Mais uma vez, você vê o gradiente de plano de fundo fixo que unifica as cores dinâmicas dos ícones, das faixas e dos elementos SVG associados. Separei os estilos para a 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 um destaque visual animado e fácil de gerenciar para o feedback do usuário. Ao usar uma sombra de caixa, evito acionar o layout com o efeito. Para isso, crio uma sombra que não é desfocada e corresponde à forma circular do elemento de miniatura. Em seguida, mudo e faço a transição do tamanho da propagação ao passar o cursor.

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

Seletores de vários navegadores

Descobri que precisava destes seletores -webkit- e -moz- para alcançar a 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

Dado o seguinte elemento de entrada HTML, 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. Marcadores 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 apresentar mais tarde para estilizar o destaque. Caso contrário, será principalmente coisas de estilo opinativas menores da minha parte. Quero que o cursor seja um ponteiro, quero 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 caixa de seleção

É importante fornecer rótulos para caixas de seleção por dois motivos. O primeiro é 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 ele seja encontrado com várias ferramentas e tecnologias, como um mouse ou um leitor de tela: <input type="checkbox" id="text-notifications" name="text-notifications">. :hover, :active e muito mais vêm sem custo financeiro com a conexão, aumentando as maneiras de interagir com seu formulário.

Destaque da caixa de seleção

Quero manter minhas interfaces consistentes, e o elemento de controle deslizante tem um destaque de miniatura interessante 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 nossas 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 a que ele está anexado era mais difícil. Veja como ficou antes e depois da correção:

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

Acessibilidade

O vídeo do YouTube mostra bem as interações do mouse, do teclado e do leitor de tela para esse componente de configurações. Vou destacar alguns dos detalhes aqui.

Opções de elementos HTML

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

Cada um deles contém dicas e sugestões para a ferramenta de navegação do usuário. Alguns elementos fornecem dicas de interação, outros conectam a interatividade e alguns 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 vai direto de um controle deslizante para o próximo. Isso acontece porque ocultamos o ícone que poderia 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 livre de passar o cursor do mouse e um comentário legível 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 claramente marcado o suficiente para que os testes de formulário funcionem bem com mouse, teclado, controles de videogame e leitores de tela.

JavaScript

expliquei como a cor de preenchimento da faixa era gerenciada em 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 é usado 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 isso, como você faria? Isso resulta em uma arquitetura de componentes 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, me envie um tweet com o link, e eu vou adicionar à seção Remixes da comunidade abaixo.

Remixes da comunidade

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