EZRShiny helps you build multi-page ‘shiny’ apps that all look and work the same way. Instead of assembling pages, navigation bars, cards and sidebars from ‘bslib’ yourself, you describe the app as a set of tabs and fill them with inputs and outputs, each built with one short function call.
This guide covers:
createEZApp() makes a folder with a working app in it,
ready to edit:
The app has an upload page and a results tab with a table. Upload any CSV and click Run to see it work, then replace the pieces with your own.
Every EZRShiny app uses the same folder layout:
myApp/
├── app.R packages, UI and server
├── Functions/ your helper functions, one or more .R files
├── Necessary_Files/ files the app needs at start, such as a data template
└── www/ images for the page, such as logos
app.R is split into numbered sections, so every app
reads in the same order:
# App Startup ----
## 1.0 Load Libraries ----
library(shiny)
library(bslib)
library(shinyjs)
library(EZRShiny)
## 2.0 Load Basics ----
options(shiny.maxRequestSize = 300 * 1024^2) # allow uploads up to 300 MB
sourceFunctions("Functions") # load every .R file in Functions/
## 3.0 Universal Vars ----
appName <- "My App"
# UI ----
ui <- UINav(...)
# Server ----
server <- function(input, output, session) { ... }
# Run App ----
shinyApp(ui = ui, server = server)sourceFunctions() loads every .R file in a
folder, so adding a helper is just a matter of saving a new file in
Functions/. The files load in alphabetical order, which is
why numbering them (1-Read_In_Data.R,
2-Plots.R) is a good habit.
UINav() builds the whole page. Everything else in the UI
goes inside it:
ui <- UINav(
logoFile = "logo.png", # from the www folder; leave out for no logo
appName = "My App", # leave out to use a global appName variable
barColor = "#1F4E79", # navigation bar color; leave out for the theme default
singleTab("Upload", ...),
biLevelTab("Results", ...)
)The navigation bar shows, from left to right: the logos, the app name, one entry per tab, and a dark mode switch.
| Argument | What it does | Default |
|---|---|---|
logoFile |
Image file names in www/, shown in order. Several logos
are allowed: c("institute.png", "lab.svg"). |
no logo |
appName |
App name shown after the logos. | the global appName variable, if there is one |
logoHeight |
Height of each logo, as a CSS size. | "40vh" |
barColor |
Navigation bar color. Text switches between light and dark to stay readable. | theme default |
theme |
A bslib::bs_theme() for the whole app, for example
bs_theme(preset = "cosmo", primary = "#005596"). |
bs_theme() |
Tabs come in two kinds: top-level tabs, which go
straight into UINav(), and sub tabs, which
go inside a top-level tab.
| Function | Holds | Put inside it |
|---|---|---|
singleTab() |
one page | inputs and outputs |
sidebarLevelTab() |
one page with a sidebar | inputs and outputs |
biLevelTab() |
a row of sub tabs | subTab(), subSidebarTab() |
triLevelTab() |
a drop-down menu, where each entry has its own row of sub tabs | triSubTab(), triSubSidebarTab() |
| Function | Holds |
|---|---|
subTab() |
inputs and outputs |
subSidebarTab() |
a sidebar, plus inputs and outputs |
triSubTab() |
a row of subTab()s or subSidebarTab()s
(inside triLevelTab() only) |
triSubSidebarTab() |
a sidebar, plus a row of sub tabs (inside triLevelTab()
only) |
subTwoColPage(leftSide, rightSide) isn’t a tab: it
splits any page into two equal columns.
Sidebars take their inputs as a list() in
sidebarElements. Everything after that fills the main part
of the page:
ui <- UINav(
appName = "Tab Tour",
# One page
singleTab("Upload",
navUpload("dataUpload", "Upload a CSV")
),
# One page with a sidebar
sidebarLevelTab("Table",
sidebarElements = list(
navSelect("columns", "Columns to show", multiple = TRUE)
),
navOutputTable("dataTable")
),
# A row of sub tabs
biLevelTab("Plots",
subSidebarTab("Scatter",
sidebarElements = list(navButton("makeScatter", "Make plot")),
navOutputPlot("scatterPlot")
),
subTab("Side by Side",
subTwoColPage(navOutputPlot("leftPlot"), navOutputPlot("rightPlot"))
)
),
# A menu of tabs, each with its own sub tabs
triLevelTab("Models",
triSubTab("Linear",
subTab("Fit", navOutputText("linearFit")),
subTab("Residuals", navOutputPlot("linearResiduals"))
),
triSubSidebarTab("Custom",
sidebarElements = list(navText("formula", "Model formula")),
subTab("Fit", navOutputText("customFit"))
)
)
)Each row of sub tabs has an id, and each sub tab has a
value. You use them in the server to show, hide or select
tabs. Both default to the title with spaces removed:
biLevelTab("Explore Data", ...) has the ID
"ExploreData", and subTab("Box Plots", ...)
has the value "BoxPlots". The server helpers remove spaces
too, so you can write the titles as they appear on screen.
The navigation bar itself has the ID "root". Top-level
tabs keep their titles exactly, spaces included.
Each tab’s contents sit in a card 85% of the window tall. To change
the height of one tab, use its height argument. To change
it for every tab, set the option before building the UI:
Every input fills the width of its sidebar or page and takes an
optional tooltipText, which adds an info icon to the
label:
Every input starts with inputId and label,
the same as in ‘shiny’. The other arguments use ’shiny’s names too:
| Function | Makes | Other arguments (defaults) |
|---|---|---|
navButton() |
a button that shows a spinner while its code runs | |
navSelect() |
a drop-down list | choices, selected (first choice),
multiple = FALSE, create = FALSE |
navUpload() |
a file upload | multiple = FALSE |
navDownload() |
a download button | |
navCheckbox() |
an on/off switch | value = FALSE (starts off) |
navText() |
a text box | value = "" |
navNumeric() |
a number box | value = 1, min = NA, max = NA
(no limits) |
navColor() |
a color picker | value = "white" |
navDate() |
a date picker | range = FALSE (one date) |
multiple = TRUE allows more than one choice or file.
create = TRUE lets the user type in options that aren’t in
choices.
navSelect("pcX", "PC for x-axis", choices = paste0("PC", 1:10))
navSelect("groups", "Groups to compare", choices = groupNames, multiple = TRUE)
navSelect("tags", "Tags", choices = c("a", "b"), multiple = TRUE, create = TRUE)Choices for navSelect() can be left out of the UI and
filled in from the server once data is loaded:
# UI
navSelect("groups", "Pick groups", multiple = TRUE)
# Server
updateSelectizeInput(session, "groups", choices = unique(theData$Group))navSpanText() adds a line of text with an info icon, for
explaining a page.
Each output is a placeholder the server fills in with the matching render function:
| UI | Server |
|---|---|
navOutputTable() |
output$id <- DT::renderDT(...) |
navOutputPlot() |
output$id <- renderPlot(...) |
navOutputPlotly() |
output$id <- plotly::renderPlotly(...) |
navOutputGirafe() |
output$id <- ggiraph::renderGirafe(...) |
navOutputPic() |
output$id <- renderImage(...) |
sideNavOutputPic() |
output$id <- renderImage(...), 40% wide, for beside
another output |
navOutputText() |
output$id <- renderText(...) |
Like inputs, outputs can have a label shown above them
and a tooltipText that adds an info icon after the label.
Use it to explain how to read a plot or table. With a tooltip and no
label, only the icon is shown.
The server of an EZRShiny app follows a few habits that keep it predictable.
Keep the user’s data in one place. Store anything
that needs to carry across the app in one reactiveValues()
list:
Set up the page on start. Hide tabs and switch off buttons and downloads that can’t be used yet:
observe({
startSection("Run on Start")
hideNavTabs(rootID = "Results", tabIDs = c("Table", "Plot"))
deactivateItems(c("runAnalysis", "resultsDownload"))
endSection("Run on Start")
})Tie work to buttons. Code in
observeEvent(input$button, ...) runs only when the button
is clicked. Code that reads inputs anywhere else reruns every time any
of those inputs change.
observeEvent(input$runAnalysis, {
startSection("Run Analysis")
## Load globals
rawData <- global$datasets$rawData
## Inputs
alpha <- input$alpha
## Do things
results <- runMyAnalysis(rawData, alpha)
## App interactions
output$resultsTable <- DT::renderDT({ results })
activateItems(c("resultsDownload"))
showNavTabs(rootID = "Results", tabIDs = c("Table", "Plot"))
nav_select("root", "Results")
## Save globals
global$datasets$results <- results
endSection("Run Analysis")
})| Helper | What it does |
|---|---|
activateItems(ids),
deactivateItems(ids) |
Switch inputs, buttons or downloads on or off. |
showNavTabs(rootID, tabIDs),
hideNavTabs(rootID, tabIDs) |
Show or hide tabs. showNavTabs() also selects the first
tab given. |
bslib::nav_select("root", "Tab Title") |
Move the user to a top-level tab. |
startSection(name), endSection(name) |
Write markers to the log, so you can see which part of the server is running. |
EZRShiny comes with a complete example app, “Titanic Explorer”, that
explores who survived the Titanic using R’s built-in
Titanic data:
It uses every piece described above:
| Tab | Built with | Shows |
|---|---|---|
| Load Data | singleTab() |
navDownload(), navCheckbox(),
navUpload(), navButton(),
navSpanText() |
| Passengers | sidebarLevelTab() |
navSelect() filled in from the server,
navOutputTable() |
| Survival | biLevelTab() with subSidebarTab() and
subTab() |
navColor(), navText(),
navOutputPlot(), navOutputPlotly(), sub tabs
revealed with showNavTabs() |
| Compare Groups | triLevelTab() with triSubTab() and
triSubSidebarTab() |
navOutputGirafe(), subTwoColPage() |
Every tab except Load Data is hidden until data is loaded, and each download is switched off until there’s something to download. Its files are a good starting point for your own app:
If your app sources a copy of the standards file from its
Functions folder:
Delete the standards file from Functions/. The
package replaces it.
Replace its source(...) line with
library(EZRShiny). Keep
sourceFunctions("Functions") to load your other
helpers.
Move options(shiny.maxRequestSize = ...) into
app.R if you need uploads over 5 MB. The package doesn’t
change options for you.
Logos and colors are now arguments of UINav()
instead of being built in:
cardHeight is no longer a global variable. Use
options(EZRShiny.cardHeight = "85vh") or each tab’s
height argument.
Inputs take TRUE/FALSE instead of short
words, and their arguments use ’shiny’s names. Calls with only an ID, a
label and tooltipText don’t change. The rest change like
this:
| Before | After |
|---|---|
navSelect("id", "Label", "Single", "Locked", choices) |
navSelect("id", "Label", choices) |
navSelect("id", "Label", "Multi", "Locked", choices) |
navSelect("id", "Label", choices, multiple = TRUE) |
navSelect("id", "Label", "Single", "Create", choices) |
navSelect("id", "Label", choices, create = TRUE) |
navUpload("id", "Label", "Single") |
navUpload("id", "Label") |
navUpload("id", "Label", "Multi") |
navUpload("id", "Label", multiple = TRUE) |
navCheckbox("id", "Label", "T") |
navCheckbox("id", "Label", value = TRUE) |
navCheckbox("id", "Label", "F") |
navCheckbox("id", "Label") |
navNumeric("id", "Label", 5) |
unchanged, but min and max now default to
no limit instead of 0 and 1 |
navNumeric(..., theValue = 5) |
navNumeric(..., value = 5) |
navColor(..., colorValue = "red") |
navColor(..., value = "red") |
navDate("id", "Label", TRUE) |
navDate("id", "Label", range = TRUE) |
Passing an old short word such as "Single" where
TRUE or FALSE is expected stops with an error
that explains the change.