Layout options

Overview

shinyGovstyle provides a set of layout functions that produce the HTML structure GOV.UK Frontend CSS expects. This vignette covers all of the layout functions available and explains how they fit together to build a complete app.

Every app starts with gov_page(), the outer wrapper for the whole page. Inside it go:


gov_page()

gov_page() is a small wrapper around bslib’s page_fluid(). bslib is now generally the recommended way to build R Shiny UIs, in place of shiny::fluidPage(), and other bslib components can be mixed freely into a shinyGovstyle app. Use gov_page() as the very first thing your UI creates: everything else in this vignette goes inside it.

ui <- gov_page(
  title = "My dashboard",
  # header(), footer(), and the rest of your page go here
)

Three arguments are worth knowing about:

Page width

By default, gov_page() lets shinyGovstyle components fill the browser window (width = "full"), which suits most dashboard-style apps. For an ordinary content page, where GOV.UK’s usual narrower reading width is more appropriate, pass width to gov_page():

gov_page(
  width = "standard", # or "three-quarters", or a CSS length like "1400px"
  # ...
)

Every shinyGovstyle component that renders part of the page frame (header(), footer(), phase_banner(), cookieBanner(), service_navigation(), gov_main_layout()) also accepts its own width argument. Setting it on gov_page() sets the default for all of them at once. Setting it on an individual component overrides that default just for that one component, which is useful if, say, everything on your page should be wider except the footer:

gov_page(
  width = "three-quarters",
  header(...),
  gov_main_layout(...),
  footer(..., width = "standard") # this one stays at the standard width
)

Content width vs page width

gov_page(width = ...) only sets the outer ceiling: how wide the page frame is allowed to get. It’s a separate thing from how wide any one piece of content looks, which you control on the content itself.

Recommended setup: for a dashboard-style app, set gov_page(width = "full") so wide tables, charts and value boxes get the room they need. Then, for any text-heavy page or section within that same app (a cookies page, a user guide, an accessibility statement), wrap its content in a div with GOV.UK’s govuk-!-width-two-thirds class, rather than letting it stretch the full width of the page. The class works on its own, without the grid, and goes back to full width on small screens.

Avoid full-width body text. GOV.UK’s own Design System guidance recommends keeping body text to a two-thirds width even on a wide page, so lines don’t get so long they’re hard to read (aim for no more than about 75 characters per line). Paragraphs on a "full"-width page with no width set produce lines that stretch the entire screen: technically valid, but noticeably harder to read. Reserve full width for content that actually benefits from the extra room, like a wide table.

In practice this means: pick a page width for the space you need (e.g. "full" for a dashboard with wide tables), then narrow the sections that need it, independently of the page:

gov_page(
  width = "full",
  gov_main_layout(
    shiny::tags$div(
      class = "govuk-!-width-two-thirds",
      gov_text("Readable body text stays narrow, even on a wide page.")
    ),
    govTable(my_wide_data)
  )
)

The example showcase app applies the same idea: its Cookies tab sits in a two-thirds column, so it reads as a normal, narrow text page even though the app itself uses gov_page(width = "full").


Page-level components

These components form the outer frame of every page, inside gov_page(). They sit outside the main content area and are consistent across all pages of your app.

+-------------------------------------------------------+
|  skip_to_main()   [visually hidden, keyboard only]    |
+-------------------------------------------------------+
|  cookieBanner()   [optional]                          |
+-------------------------------------------------------+
|  header()                                             |
+-------------------------------------------------------+
|  service_navigation()   [optional, multi-page apps]   |
+-------------------------------------------------------+
|  phase_banner()   [optional, e.g. Beta or Alpha]      |
+-------------------------------------------------------+
|                                                       |
|  gov_main_layout()   ← id = "main"                    |
|  +--------------------------------------------------+  |
|  |  your content goes here                         |  |
|  +--------------------------------------------------+  |
|                                                       |
+-------------------------------------------------------+
|  footer()                                             |
+-------------------------------------------------------+

skip_to_main()

Provides a visually hidden “Skip to main content” link that becomes visible when focused by a keyboard user. This is an accessibility requirement and should always be the first element in your UI, before the header.

skip_to_main()

By default it links to #main, which matches the id applied by gov_main_layout(). If you change the inputID argument of gov_main_layout(), pass the same value to skip_to_main().

For more information, read the documentation for the GOV.UK Skip link component.

cookieBanner()

Displays a GOV.UK-styled cookie consent banner. It requires shinyjs::useShinyjs() to be present in the UI. All element IDs within the banner are preset — see ?cookieBanner for the server-side observeEvent pattern needed to handle accept and reject interactions.

shinyjs::useShinyjs()
cookieBanner("My service name")

For more information, including when this should be used, read the documentation for the GOV.UK Cookie banner component.

phase_banner()

Displays a phase banner immediately below the header, used to indicate the maturity of your service and give a clear route for users to provide feedback.

The feedback_url argument is the recommended shortcut for the standard GOV.UK feedback wording: pass a URL (or a mailto: link for contact-style text) and the text is generated for you:

phase_banner(
  inputId = "phase-banner",
  type = "Beta",
  feedback_url = "https://example.com/feedback"
)

For custom wording, use label instead (exactly one of label or feedback_url should be supplied):

phase_banner(
  inputId = "phase-banner",
  type = "Beta",
  label = shiny::tagList(
    "This is a new service \u2014 your ",
    shiny::tags$a(class = "govuk-link", href = "#", "feedback"),
    " will help us to improve it."
  )
)

label also accepts a plain character string or raw HTML (e.g. "...<a class=\"govuk-link\" href=\"#\">feedback</a>..."); the tagList() form above is preferred for readability and to avoid hand-written HTML.

For more information on when and how to use this, read the documentation for the GOV.UK Phase banner component.

The main content area

gov_main_layout() produces a <div class="govuk-width-container"> wrapping a <main class="govuk-main-wrapper">. The outer <div> constrains content width; the <main> element carries the responsive vertical padding. Everything between the page-level components and the footer lives inside it.

gov_main_layout(
  # your content here
)

The id (default "main") is applied directly to the <main> element, which is the correct target for skip_to_main(). The <main> element also carries role="main" and tabindex="-1", so keyboard focus moves to it when the skip link is activated.


Laying out content

Content goes straight into gov_main_layout(). Items stack one below the other, so a page that is just a heading, some text and a table needs no extra layout functions:

gov_main_layout(
  heading_text("Page title", size = "l"),
  gov_text("Some introductory text."),
  govTable(my_data)
)

A simple text page

For a page that is mostly text, use the standard page width and keep the text to two-thirds of it, as the GOV.UK layout guidance recommends for readable line lengths. GOV.UK’s govuk-!-width-two-thirds class does this without the grid, and goes back to full width on small screens:

gov_page(
  width = "standard",
  gov_main_layout(
    shiny::tags$div(
      class = "govuk-!-width-two-thirds",
      heading_text("Cookies", size = "l"),
      gov_text("This page explains how we use cookies.")
    )
  )
)

Columns with gov_row() and gov_box()

Reach for the grid only when you need side-by-side columns, or a section that should sit in a grid column of a set width. It’s a two-function system: gov_row() holds the columns, and gov_box() is each column.

gov_main_layout()
└── gov_row()
    ├── gov_box(size = "two-thirds")
    │   └── [your content]
    └── gov_box(size = "one-third")
        └── [your content]

gov_row()

Creates a GOV.UK grid row. You can have multiple rows inside gov_main_layout(), each stacked vertically.

gov_main_layout(
  gov_row(
    # columns go here
  ),
  gov_row(
    # another row
  )
)

gov_box()

Creates a column within a row. The size argument controls the column width using GOV.UK Frontend’s grid classes:

size Width
"full" 100%
"one-half" 50%
"two-thirds" 66%
"one-third" 33%
"three-quarters" 75%
"one-quarter" 25%

Sizes within a row should add up to a full width. For example, "two-thirds" and "one-third" sit side by side:

gov_main_layout(
  gov_row(
    gov_box(
      size = "two-thirds",
      heading_text("Main content", size = "l"),
      # inputs, text, etc.
    ),
    gov_box(
      size = "one-third",
      heading_text("Sidebar", size = "m"),
      # supporting content
    )
  )
)

A single full-width box adds nothing over putting the content directly in gov_main_layout(), so leave the grid out in that case.

gov_text()

A wrapper that produces a <p class="govuk-body"> paragraph element. For full guidance on gov_text() and all other text functions, see the Headings and text vignette.


gov_layout() (deprecated)

gov_layout() is deprecated as of shinyGovstyle 0.3.0 and will be removed in shinyGovstyle 1.0.0. Use gov_main_layout() with gov_row() and gov_box() instead; the deprecation warning shows how each argument maps across.


Multi-page dashboards

For apps with multiple sections, use service_navigation() in combination with a hidden tab panel. The navigation bar renders as a row of links below the header; clicking a link fires a Shiny input that you use in your server to switch the visible panel.

Wiring navigation to panels

Use a hidden tab panel for the content area. service_navigation_server() then wires every nav link to its panel in a single call — no per-link observeEvent boilerplate:

# ui.R — shiny tabsetPanel
shiny::tabsetPanel(
  type = "hidden",
  id = "main_panels",
  shiny::tabPanel("Summary",       value = "summary",       "Content"),
  shiny::tabPanel("Detailed data", value = "detailed_data", "Content"),
  shiny::tabPanel("User guide",    value = "user_guide",    "Content")
)

# server.R — auto-wire the nav links to the panels
shinyGovstyle::service_navigation_server(
  session,
  tabset_id = "main_panels",
  link_to_panel = c(
    nav_summary  = "summary",
    nav_detail   = "detailed_data",
    nav_guide    = "user_guide"
  )
)

link_to_panel is a named character vector — names are nav link inputIds, values are panel values. If your inputIds already match your panel values, pass an unnamed vector instead.

bslib::navset_hidden() also responds to updateTabsetPanel(), so the same call works whether your tab panel is shiny or bslib.

For navigation that can’t be wired declaratively — next / back buttons, modal links, footer shortcuts — use navigate_to() to combine the panel switch and the nav-active update in one call:

# server.R — programmatic navigation in one call
shiny::observeEvent(input$next_btn, {
  shinyGovstyle::navigate_to(
    session, "main_panels",
    inputId = "nav_detail", panel = "detailed_data"
  )
})

navigate_to() defaults to using the nav link inputId as the target panel value. Here nav_detail’s panel value is detailed_data (per the link_to_panel mapping above), so pass panel explicitly whenever the inputId and panel value differ. See ?navigate_to for full details.

Keeping the page title in sync

service_navigation() syncs the browser tab title with the active nav link by default, both for link clicks and for programmatic navigation via update_service_navigation(). Pass page_title_suffix to get the recommended "<page> | <service>" format, or auto_page_title = FALSE to opt out. For pages where the heading differs from the nav label (or for pages not in the nav), call update_page_title() explicitly. See the Page titles section of the Headings and text vignette for the full rationale and examples.

Modularising the code

Once an app has multiple pages, it is strongly recommended to use Shiny modules to keep each page’s UI and server logic self-contained. The inst/example_app bundled with this package demonstrates this pattern: each page is a module in inst/example_app/modules/, with mod_<name>_ui() and mod_<name>_server() functions called from the top-level ui.R and server.R. This keeps individual files focused and makes it straightforward to add or remove pages without touching the overall app structure.


Complete example

The following is a minimal but complete multi-page app that uses all of the layout components covered in this vignette:

library(shiny)
library(shinyGovstyle)

ui <- gov_page(
  title = "Summary | My dashboard",
  skip_to_main(),
  header(
    org_name = "My department",
    service_name = "My dashboard"
  ),
  service_navigation(
    c(
      "Summary"  = "nav_summary",
      "About"    = "nav_about"
    ),
    page_title_suffix = "My dashboard"
  ),
  phase_banner(
    inputId = "phase",
    type = "Beta",
    label = "This is a new service."
  ),

  gov_main_layout(
    shiny::tabsetPanel(
      type = "hidden",
      id = "main_panels",

      shiny::tabPanel(
        "Summary", value = "nav_summary",
        gov_row(
          gov_box(
            size = "two-thirds",
            heading_text("Summary", size = "l"),
            gov_text("Welcome to the summary page.")
          ),
          gov_box(
            size = "one-third",
            heading_text("Quick facts", size = "m"),
            gov_text("Supporting information goes here.")
          )
        )
      ),

      shiny::tabPanel(
        "About", value = "nav_about",
        # Text-only page: two-thirds keeps it readable even though the
        # app itself uses gov_page(width = "full"). See "Content width
        # vs page width" above.
        shiny::tags$div(
          class = "govuk-!-width-two-thirds",
          heading_text("About this dashboard", size = "l"),
          gov_text("This page describes the dashboard.")
        )
      ),

      shiny::tabPanel(
        "Accessibility statement", value = "accessibility_panel",
        shiny::tags$div(
          class = "govuk-!-width-two-thirds",
          heading_text("Accessibility statement", size = "l"),
          gov_text("This page describes the accessibility of the dashboard.")
        )
      )
    )
  ),

  footer(
    links = c(`Accessibility statement` = "accessibility_footer_link")
  )
)

server <- function(input, output, session) {
  service_navigation_server(
    session,
    tabset_id = "main_panels",
    link_to_panel = c("nav_summary", "nav_about")
  )

  shiny::observeEvent(input$accessibility_footer_link, {
    shiny::updateTabsetPanel(session, "main_panels",
                             selected = "accessibility_panel")
    update_page_title(
      session,
      page_title = "Accessibility statement",
      service_name = "My dashboard"
    )
  })
}

shiny::shinyApp(ui, server)