Até aqui usamos fluidPage, sidebarLayout e afins sem perguntar o que eles são. A verdade libertadora: toda UI de Shiny é apenas HTML gerado por funções R. Quando você entende isso, deixa de ser refém dos layouts prontos e passa a construir qualquer interface — responsiva, com a estrutura que quiser, funcionando bem do celular ao monitor ultrawide. Este módulo abre a caixa-preta do HTML do Shiny e apresenta o bslib, a forma moderna de montar layouts responsivos.

Entender que a UI do Shiny é HTML produzido por htmltools::tags; dominar o sistema de grid do Bootstrap (linhas e colunas de 12 unidades) e os layouts modernos do bslib (page_sidebar, layout_columns, card, value_box); e gerenciar recursos estáticos (CSS/JS/imagens) na pasta www/.

A UI é HTML: tags e htmltools

Rode isto no console e veja o que sai:

library(shiny)

fluidPage(
  h1("Olá"),
  p("Um parágrafo")
)
# <div class="container-fluid">
#   <h1>Olá</h1>
#   <p>Um parágrafo</p>
# </div>

Funções da UI retornam HTML. Cada tag HTML tem um construtor em R via tags$*, e os mais comuns têm atalhos (h1, p, div, a, span...).

tags$div(
  class = "painel destaque",              # atributo class
  id = "meu-painel",                      # atributo id
  tags$h2("Título"),
  tags$p("Texto com ", tags$strong("negrito"), " no meio."),
  tags$a(href = "https://posit.co", "Link", target = "_blank")
)

Isso significa que você pode gerar HTML programaticamente — um cartão para cada linha de um data frame, por exemplo:

# Gera uma lista de cards a partir de dados (padrão poderosíssimo)
cards <- lapply(seq_len(nrow(produtos)), function(i) {
  tags$div(class = "card",
    tags$h3(produtos$nome[i]),
    tags$p(paste("R$", produtos$preco[i]))
  )
})
# 'cards' é uma lista de tags que você injeta na UI com tagList(cards)

htmltools (que o Shiny usa por baixo) fornece tagList() (agrupar tags), HTML() (HTML literal — cuidado com XSS, tema de notificações, validação e segurança), tags$head() (inserir no <head>) e includeCSS/includeScript.

O grid do Bootstrap: 12 colunas

O Shiny usa o Bootstrap, cujo sistema de layout é uma grade de 12 colunas. Você distribui o espaço somando até 12. Isso é a base da responsividade: em telas pequenas, as colunas empilham automaticamente.

fluidRow(
  column(width = 4, "Um terço (4/12)"),
  column(width = 8, "Dois terços (8/12)")
)

fluidRow(
  column(6, plotOutput("g1")),
  column(6, plotOutput("g2"))     # dois gráficos lado a lado, 50/50
)

O sidebarLayout clássico nada mais é que um grid pré-montado: sidebarPanel ocupa 4 colunas, mainPanel ocupa 8. Saber disso permite quebrar o layout quando o padrão não serve.

bslib: o layout moderno e responsivo

fluidPage e sidebarLayout ainda funcionam, mas o pacote bslib é a forma atual e recomendada de montar UIs: componentes responsivos, temas fáceis (identidade visual) e melhor aparência por padrão. Os blocos principais:

library(bslib)

ui <- page_sidebar(
  title = "Dashboard de Vendas",
  sidebar = sidebar(
    title = "Filtros",
    selectInput("regiao", "Região", choices = c("Todas", "N", "S")),
    sliderInput("ano", "Ano", 2020, 2024, 2024)
  ),
  # conteúdo principal
  layout_columns(
    col_widths = c(4, 4, 4),                # três colunas iguais
    value_box("Receita", "R$ 1,2M", showcase = bsicons::bs_icon("cash")),
    value_box("Pedidos", "3.402",  showcase = bsicons::bs_icon("cart")),
    value_box("Ticket",  "R$ 352", showcase = bsicons::bs_icon("receipt"))
  ),
  layout_columns(
    col_widths = c(8, 4),
    card(card_header("Série temporal"), plotOutput("serie")),
    card(card_header("Ranking"),        plotOutput("rank"))
  )
)

Os quatro componentes que você usará o tempo todo:

  • page_sidebar — página com barra lateral responsiva (colapsa no celular). Há também page_navbar (abas no topo) e page_fillable (ocupa a tela toda).
  • card — o contêiner visual padrão do dashboard moderno, com card_header, card_body, card_footer.
  • value_box — o "KPI card" (número grande + ícone) do topo de todo dashboard.
  • layout_columns — grid responsivo declarativo, onde col_widths distribui as 12 colunas.

Responsividade de verdade: col_widths por breakpoint

layout_columns permite larguras diferentes por tamanho de tela usando breakpoints() — a essência do design responsivo (três colunas no desktop, empilhadas no celular).

layout_columns(
  col_widths = breakpoints(
    sm = c(12, 12, 12),   # celular: cada card ocupa a linha inteira (empilha)
    md = c(6, 6, 12),     # tablet: dois em cima, um embaixo
    lg = c(4, 4, 4)       # desktop: três lado a lado
  ),
  card("A"), card("B"), card("C")
)

Os breakpoints do Bootstrap (sm, md, lg, xl) são larguras de tela; definir col_widths por breakpoint é como você garante que o mesmo dashboard fique bom no celular e no monitor grande — sem escrever uma linha de CSS de media query.

Painéis que se ajustam à altura: fillable e height

Dashboards modernos preenchem a tela sem barras de rolagem estranhas. bslib tem um sistema de fill: contêineres "preenchíveis" distribuem a altura disponível entre os cards.

page_fillable(
  layout_columns(
    fill = TRUE,                              # cards dividem a altura da tela
    card(full_screen = TRUE,                  # botão de expandir para tela cheia
         card_header("Mapa"), leafletOutput("mapa")),
    card(card_header("Tabela"), DTOutput("tab"))
  )
)

full_screen = TRUE adiciona um botão que expande o card para ocupar toda a tela — um recurso "de graça" que os usuários adoram para examinar um gráfico ou mapa em detalhe.

Recursos estáticos: a pasta www/

Imagens, CSS e JS próprios vão numa pasta chamada www/ ao lado do app.R. Tudo nela é servido na raiz da URL do app — um arquivo www/logo.png é acessível como logo.png.

meu_app/
├── app.R
└── www/
    ├── logo.png
    ├── custom.css
    └── script.js
ui <- page_sidebar(
  title = "App",
  tags$head(
    tags$link(rel = "stylesheet", href = "custom.css"),  # www/custom.css
    tags$script(src = "script.js")                       # www/script.js
  ),
  tags$img(src = "logo.png", height = "40px"),           # www/logo.png
  # ...
)

Regra: qualquer arquivo que o navegador precisa baixar (CSS, JS, imagens, fontes) vai em www/. Não use caminhos absolutos do sistema de arquivos — o navegador não os alcança. Essa organização vira parte da estrutura de pacote em Golem (Golem move isso para inst/app/www).

HTML sob medida quando os componentes não bastam

Às vezes você precisa de uma estrutura que nenhum componente pronto oferece. Como a UI é só HTML, basta construí-la:

# Um "badge" customizado que não existe pronto no bslib
badge <- function(texto, cor = "primary") {
  tags$span(class = paste0("badge bg-", cor), texto)
}

ui <- page_fillable(
  card(
    card_header("Status", badge("Ativo", "success")),
    "Conteúdo do card com um badge no cabeçalho."
  )
)

Combinar componentes bslib com tags sob medida é o que dá liberdade total de layout mantendo a responsividade do Bootstrap. Em identidade visual damos estilo a esse HTML; em programação em Shiny, comportamento com JS.

Toda UI de Shiny é HTML gerado por funções R — os tags$* e atalhos como h1, div e p —, o que abre a porta para gerar HTML programaticamente, como um card por linha de dados com lapply e tagList. O layout se apoia no grid de 12 colunas do Bootstrap, com fluidRow e column(width=) distribuindo espaço e empilhando em telas pequenas. A forma moderna é o bslib, com page_sidebar, page_navbar, page_fillable, card, value_box e layout_columns. A responsividade de verdade vem de col_widths = breakpoints(sm=, md=, lg=), que dá larguras diferentes por tamanho de tela sem nenhuma media query escrita à mão; e para distribuir altura, page_fillable com fill = TRUE, tendo full_screen = TRUE para expandir um card. Recursos estáticos — CSS, JS, imagens, fontes — vão em www/ e são servidos na raiz da URL; caminho absoluto do disco nunca funciona. E quando o componente pronto não basta, construir o HTML com tags dá liberdade total sem abrir mão da responsividade.

Exercícios

  1. O que realmente é retornado quando você chama fluidPage(h1("Oi")) no console R? Por que isso é importante?

    ✓ Resposta:

    É retornado HTML — uma <div class="container-fluid"><h1>Oi</h1></div> (um objeto shiny.tag que se imprime como HTML). Isso é importante porque revela que a UI do Shiny não é uma abstração mágica: são funções R que produzem HTML. Consequência prática: você pode gerar UI programaticamente (com lapply/tagList), inspecioná-la, e construir qualquer estrutura com tags$* quando os layouts prontos não bastarem.

  2. No grid do Bootstrap, você quer dois gráficos lado a lado ocupando metade da largura cada. Como escreve? E o que acontece no celular?

    ✓ Resposta:

    fluidRow(column(6, plotOutput("g1")), column(6, plotOutput("g2"))) — cada coluna ocupa 6 das 12 unidades (50%). No celular (largura pequena), o Bootstrap empilha automaticamente as colunas: cada gráfico passa a ocupar a linha inteira, um sobre o outro. Esse empilhamento responsivo é o comportamento padrão do grid de 12 colunas.

  3. Qual a diferença entre page_sidebar, page_navbar e page_fillable no bslib?

    ✓ Resposta:

    page_sidebar cria uma página com barra lateral de filtros/controles (que colapsa em telas pequenas) e uma área principal. page_navbar organiza o conteúdo em abas no topo (navbar), boa para apps com várias seções. page_fillable faz o conteúdo preencher a altura da tela sem rolagem, ideal para dashboards de painéis que dividem o espaço. Escolhe-se pela estrutura de navegação desejada: filtros laterais, abas, ou painel único preenchível.

  4. Como você faz três value_box ficarem lado a lado no desktop, dois-e-um no tablet e empilhados no celular?

    ✓ Resposta:

    Com layout_columns e col_widths = breakpoints(...):

    layout_columns(
      col_widths = breakpoints(sm = c(12,12,12), md = c(6,6,12), lg = c(4,4,4)),
      value_box(...), value_box(...), value_box(...)
    )

    lg (desktop) dá 4+4+4 = três lado a lado; md (tablet) dá 6+6+12 = dois em cima e um embaixo; sm (celular) dá 12+12+12 = cada um na sua linha. Tudo isso sem escrever media queries de CSS.

  5. Onde deve ficar o arquivo custom.css do seu app, e como você o inclui na UI? Por que não pode ser um caminho absoluto do disco?

    ✓ Resposta:

    Deve ficar na pasta www/ ao lado do app.R (www/custom.css). Inclui-se com tags$head(tags$link(rel = "stylesheet", href = "custom.css")) — note que o href é relativo à raiz da URL, sem o www/. Não pode ser caminho absoluto do disco porque quem baixa o CSS é o navegador do usuário, que só acessa URLs servidas pelo app, não o sistema de arquivos do servidor. Tudo que o browser precisa baixar (CSS, JS, imagens, fontes) vai em www/.

Fontes e leituras recomendadas