ui <- fluidPage(
titlePanel("Dataset explorer"),
sidebarLayout(
sidebarPanel(
selectInput("var", "Variable", choices = names(mtcars))
),
mainPanel(
tabsetPanel(
tabPanel("Distribution",
plotOutput("hist")
),
tabPanel("Summary",
verbatimTextOutput("summary")
),
tabPanel("Data",
tableOutput("table")
)
)
)
)
)
server <- function(input, output) {
output$hist <- renderPlot({
df <- data.frame(x = mtcars[[input$var]])
ggplot(df, aes(x = x)) +
geom_histogram(bins = 15, fill = "steelblue", colour = "white") +
labs(x = input$var) +
theme_minimal()
})
output$summary <- renderPrint(summary(mtcars[[input$var]]))
output$table <- renderTable(head(mtcars[, c(input$var, "mpg")], 10))
}
shinyApp(ui, server)Multi-Panel Applications and Interactive Graphics
This guide demonstrates how to organise larger Shiny applications across multiple tabs and grid-based layouts, and how to implement interactive graphics features including brush selection, click events, and coordinated views where interaction in one plot affects another.
Tabbed interfaces
tabsetPanel and tabPanel allow an application to have multiple distinct views, each shown when the user clicks the corresponding tab. This is the main organising structure for larger applications.
The sidebar remains visible across all tabs, so the variable selection updates all three views simultaneously.
Grid-based layouts
For applications where outputs sit side by side rather than in a tabset, use fluidRow and column. The grid is 12 units wide; a column(4, ...) takes one third of the width.
ui <- fluidPage(
titlePanel("Regression overview"),
fluidRow(
column(3,
wellPanel(
selectInput("xvar", "Predictor",
choices = setdiff(names(mtcars), "mpg")),
checkboxInput("resids", "Show residual plot", value = TRUE)
)
),
column(5, plotOutput("regplot")),
column(4, plotOutput("residplot"))
),
fluidRow(
column(12, tableOutput("coeftable"))
)
)
server <- function(input, output) {
model <- reactive({
fmla <- as.formula(paste("mpg ~", input$xvar))
lm(fmla, data = mtcars)
})
output$regplot <- renderPlot({
df <- mtcars
df$x <- df[[input$xvar]]
ggplot(df, aes(x = x, y = mpg)) +
geom_point(colour = "steelblue") +
geom_smooth(method = "lm", colour = "firebrick", se = TRUE,
fill = "firebrick", alpha = 0.15) +
labs(x = input$xvar, y = "mpg") +
theme_minimal()
})
output$residplot <- renderPlot({
if (!input$resids) return(NULL)
df <- data.frame(fitted = fitted(model()),
residual = residuals(model()))
ggplot(df, aes(x = fitted, y = residual)) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey60") +
geom_point(colour = "steelblue") +
labs(x = "Fitted values", y = "Residuals") +
theme_minimal()
})
output$coeftable <- renderTable({
cf <- coef(summary(model()))
data.frame(Term = rownames(cf), round(cf, 4))
}, rownames = FALSE)
}
shinyApp(ui, server)One thing to notice about the coefficient table is that although column(12, ...) gives it the full row width, the table itself sits narrow and left-aligned. This is not a Shiny quirk but an HTML one: a <table> element sizes to its content by default and does not stretch to fill its container. If you want the table to span the full column, pass width = "100%" to renderTable. Whether that is an improvement depends on the data — a table with a handful of short columns usually reads better at natural width than stretched across the page.
A 2×2 grid layout
The 3-column-plus-full-width-row arrangement above is a reasonable illustration of how fluidRow and column compose, but it is somewhat lopsided: the top row has three items (one of which disappears when the checkbox is unchecked) and the bottom row is a single table. A more balanced structure places the main plot and the controls in the top row and the two diagnostic outputs — the coefficient table and the residual plot — side by side in the bottom row.
ui <- fluidPage(
titlePanel("Regression overview"),
fluidRow(
column(3,
wellPanel(
selectInput("xvar", "Predictor",
choices = setdiff(names(mtcars), "mpg")),
checkboxInput("resids", "Show residual plot", value = TRUE)
)
),
column(9, plotOutput("regplot"))
),
fluidRow(
column(6, tableOutput("coeftable")),
column(6, plotOutput("residplot"))
)
)
server <- function(input, output) {
model <- reactive({
fmla <- as.formula(paste("mpg ~", input$xvar))
lm(fmla, data = mtcars)
})
output$regplot <- renderPlot({
df <- mtcars
df$x <- df[[input$xvar]]
ggplot(df, aes(x = x, y = mpg)) +
geom_point(colour = "steelblue") +
geom_smooth(method = "lm", colour = "firebrick", se = TRUE,
fill = "firebrick", alpha = 0.15) +
labs(x = input$xvar, y = "mpg") +
theme_minimal()
})
output$residplot <- renderPlot({
if (!input$resids) return(NULL)
df <- data.frame(fitted = fitted(model()),
residual = residuals(model()))
ggplot(df, aes(x = fitted, y = residual)) +
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey60") +
geom_point(colour = "steelblue") +
labs(x = "Fitted values", y = "Residuals") +
theme_minimal()
})
output$coeftable <- renderTable({
cf <- coef(summary(model()))
data.frame(Term = rownames(cf), round(cf, 4))
}, rownames = FALSE)
}
shinyApp(ui, server)The main regression plot now has more room across the top. The coefficient table and residual diagnostic sit at the same level in the bottom row, which groups related outputs together and gives the layout a more consistent structure. When the residual checkbox is unchecked the right half of the bottom row is simply empty, which is less distracting than a vanishing column in a busier top row.
Brush selection
plotOutput accepts a brush argument that enables rectangular selection in the plot. The string value passed to brush becomes the name of the reactive value that holds the current selection coordinates. brushedPoints is a helper function that takes the original data frame and the brush value and returns only the rows whose plotted points fall within the rectangle.
brushedPoints(df, brush, xvar, yvar) works by comparing the x and y coordinates of each row (computed from the xvar and yvar columns) against the rectangle defined by the brush. It returns a filtered data frame with the same columns as df, containing only the selected rows. input$plot_brush is NULL when nothing is selected, so brushedPoints returns zero rows if the user has not brushed anything yet.
The table should only show the columns that are relevant to the plot. Displaying every column in the data frame when the scatter shows only two variables is confusing, because the user has no way to know which columns were used for the selection.
ui <- fluidPage(
titlePanel("Select points"),
fluidRow(
column(8, plotOutput("scatter", brush = "plot_brush")),
column(4, tableOutput("selected"))
)
)
server <- function(input, output) {
output$scatter <- renderPlot({
ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point(colour = "steelblue", size = 3) +
theme_minimal()
})
output$selected <- renderTable({
pts <- brushedPoints(mtcars, input$plot_brush, xvar = "wt", yvar = "mpg")
pts[, c("wt", "mpg", "cyl", "hp")] # show only the plotted and related columns
})
}
shinyApp(ui, server)Drag a rectangle over the scatter plot and the table updates to show the selected cars. The table shows wt, mpg, cyl, and hp, which is enough context to identify the selected points without displaying unrelated columns.
Zooming with a brush
Rather than filtering rows for a table, the brush coordinates can be fed directly into coord_cartesian() to produce a zoomed view of the selected region. The left panel shows the full scatter plot with a brush; the right panel re-draws the same plot but with its axes constrained to the brush rectangle, so the selected area fills the panel. req(input$plot_brush) suppresses the right panel until the user has drawn a selection, which avoids an error when the brush value is NULL.
ui <- fluidPage(
titlePanel("Select points"),
fluidRow(
column(6, plotOutput("scatter", brush = "plot_brush")),
column(6, plotOutput("zoom"))
)
)
server <- function(input, output) {
output$scatter <- renderPlot({
ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point(colour = "steelblue", size = 3) +
theme_minimal()
})
output$zoom <- renderPlot({
req(input$plot_brush)
ggplot(mtcars, aes(x = wt, y = mpg)) +
geom_point(colour = "steelblue", size = 3) +
coord_cartesian(
xlim = c(input$plot_brush$xmin, input$plot_brush$xmax),
ylim = c(input$plot_brush$ymin, input$plot_brush$ymax)
) +
theme_minimal()
})
}
shinyApp(ui, server)Drag a rectangle on the left plot and the right plot zooms into exactly that region. No data filtering is involved: all the original points are still passed to ggplot, but coord_cartesian() clips the view to the brush boundaries rather than dropping any observations. This is preferable to setting xlim and ylim inside aes() because coord_cartesian zooms without discarding points, which matters if a smoothing layer or other stat is present.
Click events and nearestPoints
click = "plot_click" enables single-point selection. nearPoints returns the data frame rows closest to where the user clicked.
ui <- fluidPage(
plotOutput("scatter", click = "plot_click"),
verbatimTextOutput("info")
)
server <- function(input, output) {
output$scatter <- renderPlot({
ggplot(mtcars, aes(x = hp, y = mpg, label = rownames(mtcars))) +
geom_point(colour = "steelblue", size = 3) +
theme_minimal()
})
output$info <- renderPrint({
np <- nearPoints(mtcars, input$plot_click,
xvar = "hp", yvar = "mpg",
threshold = 10, maxpoints = 1)
if (nrow(np) == 0) {
cat("Click a point.")
} else {
print(np[, c("mpg", "hp", "wt", "cyl")])
}
})
}
shinyApp(ui, server)Coordinated views
A powerful pattern is to have selection in one plot filter or highlight the data in another. Both plots read from the same reactive brush value so they stay in sync.
ui <- fluidPage(
titlePanel("Coordinated scatter plots"),
fluidRow(
column(6, plotOutput("p1", brush = "shared_brush")),
column(6, plotOutput("p2"))
),
fluidRow(
column(12, verbatimTextOutput("n_selected"))
)
)
server <- function(input, output) {
selected <- reactive({
brushedPoints(mtcars, input$shared_brush,
xvar = "wt", yvar = "mpg")
})
make_plot <- function(xvar, yvar, highlight) {
p <- ggplot(mtcars, aes(x = .data[[xvar]], y = .data[[yvar]])) +
geom_point(colour = "grey70", size = 2.5) +
theme_minimal()
if (nrow(highlight) > 0) {
p <- p + geom_point(data = highlight,
colour = "firebrick", size = 3)
}
p + labs(x = xvar, y = yvar)
}
output$p1 <- renderPlot(make_plot("wt", "mpg", selected()))
output$p2 <- renderPlot(make_plot("hp", "mpg", selected()))
output$n_selected <- renderPrint({
cat(nrow(selected()), "of", nrow(mtcars), "points selected.\n")
})
}
shinyApp(ui, server)Brush a region in the left plot and the same cars are highlighted in red in the right plot. The reactive expression selected() does the filtering once; both output$p1 and output$p2 consume it.
Structuring code in larger applications
As applications grow, keeping all code in a single app.R file becomes unwieldy. Some conventions that help:
- Define helper functions (plot builders, data transformations) above the
uiandserverdefinitions, or in a separateR/directory that Shiny loads automatically. - Name reactive expressions and outputs systematically, for example
data_filtered,data_modelled,plot_scatter, so their roles are clear. - Group
observeEventandrenderPlotblocks by the part of the UI they belong to, with a comment marking each section. - Keep render blocks thin: do the computation in a reactive expression and only pass the result to the render function.