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:
lang: the page’s language,
"en" by default. Screen readers rely on this to pick the
right pronunciation and voice, so it matters for accessibility even on a
page you never plan to translate. Set it to "cy" for a
Welsh-language service, for example.
description: a short summary of the
page, added as a <meta name="description"> tag for
search engines and some assistive technology. Optional; there’s no tag
at all if you leave it out. Keep it to one concise sentence: search
engines typically truncate meta descriptions at around 150-160
characters.
width (see Page
width below): sets a default width for every shinyGovstyle component
used inside it, so you don’t have to repeat width = on each
one individually.
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"
# ...
)
"full" (the default): no maximum
width, so the page fills the browser window, and also removes the gaps
between grid columns, so content can run edge to edge.
"standard": GOV.UK’s usual 960px
content width, the better choice for an ordinary content-style
page.
"three-quarters": three-quarters of
the browser window’s width (never narrower than standard), keeping
GOV.UK’s own left/right margins.
- A CSS length, e.g.
"1400px" or
"90vw", for a specific maximum width instead of one of the
presets above.
Every shinyGovstyle component that renders part of the page frame
(header(), footer(), banner(),
cookieBanner(), service_navigation(),
gov_main_layout(), gov_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 is
controlled by gov_row()/gov_box(size = ...)
(see The primary layout system
below).
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
gov_box(size = "two-thirds"), GOV.UK’s standard
reading-width column, rather than letting it stretch the full width of
the page.
Avoid full-width body text. GOV.UK’s own Design System
guidance recommends keeping body text to a two-thirds-width column
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). Rendering
paragraphs in a gov_box(size = "full") on a
"full"-width page produces 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 use gov_box(size = ...) around each
section of content to control how wide that section looks,
independently of the page:
gov_page(
width = "full",
gov_main_layout(
gov_row(
gov_box(
size = "two-thirds",
gov_text("Readable body text stays narrow, even on a wide page.")
)
),
gov_row(
gov_box(size = "full", govTable(my_wide_data))
)
)
)
The example showcase app does exactly this: its Cookies tab sits
inside a gov_box(size = "two-thirds"), 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] |
+-------------------------------------------------------+
| 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.
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.
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:
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):
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.
The primary layout system
Inside gov_main_layout(), content is structured using a
three-function grid system: gov_row(),
gov_box(), and optionally gov_text().
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:
"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
)
)
)
For a simple single-column layout, use
size = "full":
gov_main_layout(
gov_row(
gov_box(
size = "full",
heading_text("Page title", size = "l")
)
)
)
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() — legacy alternative
Warning: gov_layout() is not
recommended for new development and may be removed in a future release.
Use gov_main_layout() with gov_row() and
gov_box() instead.
gov_layout() is a single-function alternative that
combines a width container and a column in one call:
gov_layout(
size = "two-thirds",
heading_text("Page title", size = "l"),
# content
)
It is well suited to simple, single-column apps where you want a
width constraint without setting up the full
gov_main_layout() / gov_row() /
gov_box() hierarchy.
As soon as your app needs more than one column, multiple
rows, or a combination of widths, switch to the full system.
Nesting gov_layout() inside gov_main_layout()
will produce doubled-up width container HTML and cause the content to
appear visually inset from the page-level components.
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.
Setting up navigation links
Pass a named character vector to service_navigation().
The names are displayed as link text; the values become the
inputIDs:
service_navigation(
c(
"Summary" = "nav_summary",
"Detailed data" = "nav_detail",
"User guide" = "nav_guide"
)
)
If you pass an unnamed vector, inputIDs are auto-generated by
lowercasing the text and replacing non-alphanumeric characters with
underscores (e.g. "Detailed data" becomes
detailed_data).
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"
),
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",
gov_row(
gov_box(
# 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.
size = "two-thirds",
heading_text("About this dashboard", size = "l"),
gov_text("This page describes the dashboard.")
)
)
),
shiny::tabPanel(
"Accessibility statement", value = "accessibility_panel",
gov_row(
gov_box(
size = "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)