Você já deve ter ouvido falar em Container Queries, uma proposta que permitiria a desenvolvedores web estilizarmos elementos DOM com base no tamanho do elemento contentor (“que contém”) ao invés do tamanho da viewport do navegador. Mas já ouviu sobre Responsive Components?

Se você é um desenvolvedor a par das discussões envolvendo responsividade, provavelmente já ouviu falar sobre Container Queries antes. Desde quando toda a festa de web design responsivo começou, já havia desenvolvedores pedindo por tal recurso (inicialmente Element Queries, depois mudando para Container Queries) — de fato, Container Queries podem ser o recurso CSS mais solicitado que os navegadores ainda não têm.

Já existem muitos, muitos, muitos artigos explicando exatamente porquê container queries são difíceis de fazer no CSS e porquê os fabricantes de navegadores hesitaram em implementá-las. Essa discussão não precisa ser revivida neste post.

Ao invés de focar estritamente na proposta formal de CSS chamada “Container Queries” (Consultas de Contêiner), este artigo abordará o conceito mais amplo de construção de componentes que respondem a seu “ambiente” — responsive components. E se você aceitar esse “enquadramento” maior, realmente já existem novas APIs que permitem que se obtenha isso. Isso mesmo: não é preciso esperar Container Queries chegarem oficialmente para começar a criar componentes responsivos; é possível começar agora!

A estratégia proposta neste artigo permite o uso hoje e é projetada como um aprimoramento, então os navegadores que não suportam as APIs mais recentes ou não executam JavaScript funcionarão exatamente como fazem atualmente. Também é simples de implementar, altamente performática e não requer nenhuma ferramenta especial, bibliotecas ou frameworks.

Para ver alguns exemplos dessa estratégia em ação, existe o site de demonstração Responsive Components, em que cada exemplo aponta para o código-fonte CSS.

Mas antes de começar a realmente ver alguns exemplos, é importante saber como a estratégia proposta funciona.

A estratégia de Responsive Components

A maioria das estratégias/metodologias de web design responsivo funcionam de acordo com estes 2 princípios fundamentais (e aqui não será diferente):

  1. Para cada componente, define-se primeiro um conjunto-base de estilos genéricos que se aplicam independentemente do ambiente em que o componente esteja contextualizado/inserido
  2. Em seguida, definem-se adições ou substituições para os estilos-base que se aplicam em condições ambientais específicas

O poder desses princípios é que eles funcionam mesmo se o navegador não suportar os recursos necessários para cumprir ou permitir condições de ambiente específicas. E isso inclui casos em que o recurso requer JavaScript — usuários com JavaScript desativado obterão os estilos-base e eles funcionarão normalmente.

Na maioria dos casos, os estilos-base definidos no item “1” acima são estilos que funcionam para os menores tamanhos de viewport possíveis (uma vez que viewports menores tendem a ser mais restritivas do que as maiores) e elas não estão envolvidas com nenhum tipo de media query (o que significa que se aplicam em toda parte).

Eis um exemplo que define estilos-base para .MyComponent e depois os substituem em 2 breakpoints arbitrários, 36em e 48em:

Claro, esses breakpoints usam media queries, então eles se aplicam ao tamanho da viewport do navegador. O que os defensores de container queries procuram é a capacidade de fazer algo como isto (nota: esta é a sintaxe proposta; não a sintaxe oficial):

Infelizmente, a sintaxe acima não funciona em nenhum navegador atualmente e não funcioná tão cedo… No entanto, funciona hoje algo mais ou menos como:

Claro, este código assume que os contêineres de componentes têm as classes corretas adicionadas a eles (neste exemplo, .MD e .LG). Mas ignorando esse detalhe por enquanto, a segunda sintaxe ainda faz sentido para qualquer desenvolvedor CSS que quer construir um componente responsivo.

Quer esteja escrevendo uma container query como uma query de comparação de largura (a primeira sintaxe) ou usando classes de breakpoint nomeadas (a segunda sintaxe), os estilos ainda são declarativos e funcionalmente os mesmos. Contanto que seja possível definir breakpoints nomeados, aparentemente não há um benefício claro a favor de um ou de outro.

E para que o restante do artigo fique claro, aqui está o mapeamento das classes/breakpoints (no qual min-width se aplica ao contêiner, não à viewport):

Breakpoint Largura do Container
SM min-width: 24em (384px)
MD min-width: 36em (576px)
LG min-width: 48em (768px)
XL min-width: 60em (960px)

Agora é preciso garantir que os elementos de contêiner tenham sempre as classes de breakpoint corretas; então os seletores de componentes corretos irão corresponder (match).

Observando redimensionamento de contêineres

Quase desde sempre em desenvolvimento web foi possível observar mudanças em window, mas também quase sempre foi difícil ou impossível (pelo menos de uma forma significativa) observar mudanças de tamanho em elementos individuais de DOM. Isso mudou quando o Chrome 64 disponibilizou ResizeObserver.

ResizeObserver, seguindo os passos de APIs semelhantes, como MutationObserver e IntersectionObserver, permite que sejam observadas mudanças de tamanho nos elementos DOM de uma forma altamente performática.

Aqui está um possível código necessário para fazer com que o CSS mostrado anteriormente funcione com ResizeObserver:

Este exemplo usa a sintaxe ES5 porque (como será explicado mais adiante) pode ser interessante inserir este código diretamente no HTML em vez de incluí-lo em um arquivo JavaScript externo. A sintaxe mais antiga é usada para garantir um amplo suporte de navegadores.

O código cria uma instância única de ResizeObserver com uma função de callback. Em seguida, consulta o DOM por elementos com o atributo data-observe-resizes e começa a observá-los. A callback — que é invocada inicialmente após a observação e novamente após qualquer alteração — verifica o tamanho de cada elemento e adiciona (ou remove) as classes de breakpoint correspondentes.

Em outras palavras, esse código transformará um elemento de contêiner com 600px de largura assim:

Para:

E essas classes serão atualizadas automática e instantaneamente sempre que o tamanho do contêiner mudar. Com isso, os seletores .SM e .MD corresponderão (mas não os seletores .LG ou .XL) e esse código funcionará perfeitamente.

Customizando breakpoints

O código na callback de ResizeObserver acima define um conjunto padrão de breakpoints, mas também permite que sejam especificados breakpoints personalizados por componente, passando um JSON através do atributo data-breakpoints.

A estratégia recomendada é alterar o código acima para usar qualquer mapeamento-padrão de breakpoints que faça mais sentido para o projeto que se está trabalhando e, em seguida, especificar inline um conjunto de breakpoints específicos para qualquer componente que precise:

O site Responsive Components tem um exemplo de um componente que configura seus próprios breakpoints juntamente com componentes usando breakpoints padrão.

Lidando com alterações dinâmicas no DOM

O exemplo de código acima apenas funciona para elementos de contêiner que já estão no DOM.

Para sites baseados em conteúdo (content-based sites), isso geralmente é bom, mas para sites mais complexos, cujo DOM muda constantemente, será preciso se certificar de que a observação de todos os elementos de contêiner adicionados dinamicamente está acontecendo.

Uma solução “one-size-fits-all” para este problema é expandir o snippet acima para incluir um MutationObserver que acompanha todos os elementos de DOM adicionados. Esta é a abordagem usada no site Responsive Components, que funciona bem para sites de pequeno e médio porte com modificações de DOM limitadas.

Para sites maiores, com atualizações freqüentes no DOM, já se usa algo como Custom Elements ou algum framework com métodos de lifecycle que rastreiam quando elementos são adicionados/removidos do DOM — se for este o caso, provavelmente é melhor apenas fazer um hook a esses mecanismos.

Por exemplo, um elemento personalizado <responsive-container> poderia ser algo assim:

Embora seja tentador criar um novo ResizeObserver para cada elemento de contêiner, é realmente muito melhor criar um único que observa muitos elementos. Para saber mais, veja as descobertas de Aleks Totic sobre o desempenho do ResizeObserver na lista de discussão blink-dev.

Componentes aninhados

Nas primeiras experimentações com esta estratégia, cada componente não foi envolvido com um elemento de contêiner. Em vez disso, foi usado um único elemento de contêiner por área de conteúdo distinta (header, sidebar, footer etc.) e, no CSS, usados combinadores de descendentes (descendant combinators) ao invés de combinadores de filho (child combinators).

Isso resultou em marcação e estilo mais simples, mas rapidamente se desfez quando se tentou aninhar componentes dentro de outros componentes (o que não é muito raro). O problema é que, com a abordagem do combinador descendente, os seletores corresponderam a vários containers ao mesmo tempo…

Após a construção de alguns demos não-triviais, tornou-se claro que uma estrutura direta filho/pai para cada componente e seu contêiner era muito mais fácil de gerenciar e dimensionar. Observe que os recipientes ainda podem conter mais de um componente, desde que cada um deles seja um descendente direto.

Seletores avançados e abordagens alternativas

Como já explicado, a estratégia descrita neste artigo traz uma abordagem aditiva para estilizar componentes: inicia-se com estilos-base para adicionar mais estilos em seguida. Entretanto, esta não é a única abordagem possível. Em algumas situações, pode ser preciso definir estilos que correspondam exclusivamente e somente se apliquem a um breakpoint específico (ex: ao invés de (min-width: 48em), algo como (min-width: 48em) and (max-width: 60em)).

Se fosse este o caso, seria preciso ajustar um pouco o callback de ResizeObserver para aplicar o nome da classe do breakpoint que corresponde atualmente (currently-matching breakpoint). Então, se o componente estivesse em seu tamanho “grande”, ao invés de definir o nome da classe SM MD LG, seria simplesmente LG.

No CSS, seria possível escrever seletores como:

Importante notar que, ao usar a estratégia para correspondência aditiva, ainda pode haver correspondência exclusiva por meio de um seletor como .MD:not(.LG) — embora a leitura desse seletor seja mais custosa (para humanos e máquinas).

No final das contas, é possível escolher qualquer convenção que faça mais sentido para o projeto em questão; que funcione melhor para a situação concreta que se apresente.

O seletor :matches() ainda não é bem suportado nos navegadores atuais. Mas é possível usar ferramentas como postcss-selector-matches e similares para transpilar em algo que funcione de maneira cross-browser.

Breakpoints de altura

Até agora, todos os exemplos focaram em breakpoints de largura. Não é surpresa, já que a maioria esmagadora das implementações de web design responsivo usam a largura e nada mais (pelo menos quando se trata de dimensões da viewport). Contudo, nada na estratégia apresentada no artigo evitaria que um componente respondesse à altura de seu container, já que ResizeObserver reporta ambas as dimensões.

Portanto, se fosse preciso observar mudanças de altura, seria possível definir um conjunto separado de classes de breakpoints — talvez com um prefixo W- para breakpoints de largura e H- para breakpoints de altura.

Suporte dos navegadores

Na data de publicação deste artigo, ResizeObserver só é suportado no Chrome, mas não há absolutamente nenhuma razão para que não seja possível usá-lo imediatamente. A estratégia descrita aqui é intencionalmente projetada para funcionar bem se o navegador não suportar ResizeObserver ou mesmo se o JavaScript estiver desabilitado.

Em qualquer um desses casos, os visitantes verão os estilos-padrão — que devem ser mais do que suficientes para oferecer uma boa experiência. Na verdade, eles provavelmente serão os mesmos estilos que você já está oferecendo hoje.

A abordagem recomendada é usar media queries para o layout do site e, em seguida, essa estratégia de componentes responsivos para componentes específicos que precisem dele (muitos não irão precisar).

Se for preciso fornecer uma UI consistente em todos os navegadores, é possível usar o polyfill de ResizeObserver, que oferece um excelente suporte (IE9+). Entretanto, certifique-se de que o polyfill seja carregado somente quando for realmente preciso.

Também é importante levar em consideração que polyfills tendem a ser mais lentos em dispositivos móveis e, dado que componentes responsivos importam mais em viewports maiores, provavelmente não é preciso carregar o polyfill se o visitante estiver em um dispositivo com viewport pequena.

O site de demonstração Responsive Components leva em conta esta última abordagem: ele carrega o polyfill somente se o navegador não suporta ResizeObserver e se a largura da viewport é pelo menos 48em.

Limitações e melhorias futuras

No geral, a estratégia de componentes responsivos descrita neste artigo é incrivelmente versátil e tem poucas desvantagens. A partir de agora, é altamente recomendado que sites com áreas de conteúdo cujos tamanhos possam mudar independentemente da viewport implementem uma estratégia de componentes responsivos ao invés de confiar somente em media queries (ou alguma solução JavaScript que não aproveita ResizeObserver).

Isto posto, passemos a algumas limitações que valem a pena serem discutidas.

Não é CSS puro

Uma desvantagem óbvia desta solução é que requer mais do que apenas CSS para ser implementada. Além de definir os estilos em CSS, também é preciso se valer de marcação no HTML e coordenar ambos com JavaScript.

Certamente uma solução baseada em CSS puro é a ideal; é o objetivo final. Mas não há motivos sólidos para impedir o uso desta estratégia hoje.

“Flash of un/incorrectly-styled content”

Na maioria dos casos, é uma prática recomendada carregar todo o JavaScript de forma assíncrona, mas, neste caso, o carregamento assíncrono pode levar os componentes a renderizar inicialmente no breakpoint padrão para depois alternar para um breakpoint maior depois de o JavaScript ser carregado.

Embora esta não seja a pior experiência do mundo, é algo que definitivamente não estaria na lista de preocupações em uma solução de CSS puro. E uma vez que esta estratégia envolve coordenação com JavaScript, também é preciso prestar atenção quando estilos e breakpoints são aplicados para evitar esse re-layout.

Uma das maneiras mais ágeis para lidar com isso é incorporar o código de container query no final dos templates de HTML, garantindo que seja executado o mais rápido possível. Em seguida, adicionando-se uma classe ou atributo aos elementos de contêiner uma vez que sejam inicializados e estejam visíveis para que se saiba quando é seguro mostrá-los (e se certificar de considerar o caso em que o JavaScript está desativado ou cause erros quando executado). É possível ver um exemplo disso no site de demonstração.

As unidades são baseadas em pixels

Muitos desenvolvedores de CSS (se não a maioria) preferem definir estilos com base em unidades com mais relevância contextual (por exemplo: em, vh etc.), enquanto o ResizeObserver, como a maioria das API de DOM, retorna todos seus valores em px.

No momento, não há realmente nenhuma boa maneira para contornar isso.

No futuro, uma vez que os navegadores implementem CSS Typed OM (uma das novas especificações CSS Houdini), será possível converter fácil e economicamente entre várias unidades de CSS.

Até lá, o custo de realizar esta conversão provavelmente afetaria o desempenho ao ponto de prejudicar a experiência do usuário.

Conclusão sobre Responsive Components

Este artigo descreve uma estratégia para o uso de tecnologias web modernas para criar responsive components: elementos DOM que podem atualizar seu estilo e layout em resposta a mudanças no tamanho de seu contêiner.

Enquanto as tentativas anteriores de construir componentes responsivos eram valiosas em sua exploração/tentativa, limitações na plataforma significavam que essas soluções eram sempre grandes demais, lentas demais ou ambas. Felizmente, agora existem APIs de navegador que permitem construir soluções eficientes e performáticas.

A estratégia descrita neste artigo:

A estratégia apresentada neste artigo é “pronta para produção” (production-ready), mas ainda estamos muito nos primeiros estágios da coisa.

À medida que a comunidade de desenvolvimento web começar a mudar o design de componentes de viewport ou orientado a dispositivo (device-oriented) para orientado a contêiner (container-oriented), certamente todos veremos o emergir de possibilidades e melhores práticas até então inimagináveis!