| name | shiny-dev |
| description | Build Shiny web applications in R. Use when creating or editing Shiny apps, modules, reactive logic, bslib layouts, dashboards, or LLM-powered chat apps.
|
Shiny
Shiny is an R framework for building interactive web applications. This skill covers modern Shiny (1.7+) with bslib layouts, modules, and current best practices.
Guides
Detailed guides on specific topics (read via btw MCP or WebFetch when relevant):
Core Patterns
Single-file app (app.R): simplest structure, everything in one file.
library(shiny)
library(bslib)
ui <- page_sidebar(
title = "My App",
sidebar = sidebar(
sliderInput("n", "Sample size", 10, 1000, 100)
),
card(
card_header("Histogram"),
plotOutput("plot")
)
)
server <- function(input, output, session) {
output$plot <- renderPlot({
hist(rnorm(input$n), main = NULL)
})
}
shinyApp(ui, server)
Multi-file app (ui.R + server.R): for larger apps. Files in R/ are auto-sourced.
myapp/
├── R/
│ ├── mod_sidebar.R
│ └── mod_plot.R
├── ui.R
├── server.R
└── global.R
Module pattern: the building block of scalable Shiny apps.
counterUI <- function(id, label = "Counter") {
ns <- NS(id)
tagList(
actionButton(ns("button"), label = label),
verbatimTextOutput(ns("out"))
)
}
counterServer <- function(id) {
moduleServer(id, function(input, output, session) {
count <- reactiveVal(0)
observeEvent(input$button, {
count(count() + 1)
})
output$out <- renderText(count())
count
})
}
Using the module:
ui <- page_fluid(
counterUI("counter1", "Click me"),
counterUI("counter2", "Me too")
)
server <- function(input, output, session) {
counterServer("counter1")
counterServer("counter2")
}
Page Layouts (bslib)
Always use bslib for layouts in modern Shiny apps. Do NOT use fluidPage(), sidebarLayout(), or navbarPage() unless the project already uses them.
page_sidebar(..., sidebar, title, fillable, theme)
Dashboard with sidebar. The default choice for most apps.
page_sidebar(
title = "Dashboard",
sidebar = sidebar(
title = "Controls",
selectInput("var", "Variable", choices = names(mtcars)),
checkboxInput("smooth", "Add smoother", TRUE)
),
layout_columns(
col_widths = c(6, 6),
card(card_header("Plot"), plotOutput("plot")),
card(card_header("Summary"), verbatimTextOutput("summary"))
)
)
page_fillable(..., title, theme, padding, gap, fillable_mobile)
Full-height page that fills the browser. Good for maps, large visualizations.
page_fluid(..., title, theme)
Standard Bootstrap fluid page. Good for document-style layouts.
page_navbar(..., title, sidebar, header, footer, theme)
Multi-page app with a top navigation bar.
page_navbar(
title = "Multi-Page App",
nav_panel("Tab 1", plotOutput("plot1")),
nav_panel("Tab 2", tableOutput("table1")),
nav_spacer(),
nav_item(actionButton("about", "About"))
)
Layout Components (bslib)
layout_columns(..., col_widths, row_heights, fill, fillable, gap, class)
Grid layout. col_widths takes a vector summing to 12 (Bootstrap grid).
layout_columns(
col_widths = c(4, 8),
card("Narrow column"),
card("Wide column")
)
layout_column_wrap(..., width, fixed_width, heights_equal, fill, fillable, height, gap, class)
Auto-wrapping grid. width sets the minimum column width.
layout_column_wrap(
width = "250px",
value_box("Metric 1", 42, showcase = bsicons::bs_icon("bar-chart")),
value_box("Metric 2", 87, showcase = bsicons::bs_icon("graph-up")),
value_box("Metric 3", 15, showcase = bsicons::bs_icon("clock"))
)
sidebar(title, ..., open, width, position, id, bg, fg)
Sidebar panel. Use inside page_sidebar() or layout_sidebar().
card(..., full_screen, height, max_height, min_height, fill, class, wrapper, id)
General-purpose container.
card(
full_screen = TRUE,
card_header("Interactive Plot"),
card_body(plotOutput("plot")),
card_footer("Source: mtcars dataset")
)
value_box(title, value, ..., showcase, showcase_layout, full_screen, theme, height)
Metric display box with optional icon.
value_box(
title = "Total Sales",
value = textOutput("total"),
showcase = bsicons::bs_icon("currency-dollar"),
theme = "primary"
)
accordion(..., id, open, multiple, class)
Collapsible sections.
accordion(
id = "filters",
accordion_panel("Date Range", dateRangeInput("dates", NULL)),
accordion_panel("Categories", checkboxGroupInput("cats", NULL, choices = letters[1:5]))
)
navset_card_tab(..., id, selected, title, sidebar, header, footer)
Tabbed card. Also: navset_card_pill(), navset_card_underline().
navset_card_tab(
title = "Results",
nav_panel("Plot", plotOutput("plot")),
nav_panel("Table", tableOutput("table")),
nav_panel("Code", verbatimTextOutput("code"))
)
Input Components
Text & Numbers
textInput(inputId, label, value, width, placeholder)
textAreaInput(inputId, label, value, width, height, rows, placeholder, resize)
numericInput(inputId, label, value, min, max, step, width)
passwordInput(inputId, label, value, width, placeholder)
Selection
selectInput(inputId, label, choices, selected, multiple, selectize, width, size) — dropdown
selectizeInput(inputId, label, choices, selected, multiple, options, width) — enhanced dropdown
radioButtons(inputId, label, choices, selected, inline, width, choiceNames, choiceValues)
checkboxInput(inputId, label, value, width) — single checkbox
checkboxGroupInput(inputId, label, choices, selected, inline, width, choiceNames, choiceValues)
Ranges & Dates
sliderInput(inputId, label, min, max, value, step, round, ticks, animate, width, sep, pre, post, timeFormat, timezone, dragRange)
dateInput(inputId, label, value, min, max, format, startview, weekstart, language, width, autoclose, datesdisabled, daysofweekdisabled)
dateRangeInput(inputId, label, start, end, min, max, format, startview, weekstart, language, separator, width, autoclose)
Actions
actionButton(inputId, label, icon, width, ...)
actionLink(inputId, label, icon, ...)
fileInput(inputId, label, multiple, accept, width, buttonLabel, placeholder, capture)
downloadButton(outputId, label, class, icon, ...)
Dynamic UI
uiOutput(outputId, container, fill, ...) / renderUI({ ... })
insertUI(selector, where, ui, multiple, immediate, session)
removeUI(selector, multiple, immediate, session)
Output Components
plotOutput(outputId, width, height, click, dblclick, hover, brush, fill) / renderPlot({ ... })
tableOutput(outputId) / renderTable({ ... })
dataTableOutput(outputId) / renderDataTable({ ... }) — use DT::DTOutput for full features
textOutput(outputId, container, inline) / renderText({ ... })
verbatimTextOutput(outputId, placeholder) / renderPrint({ ... })
imageOutput(outputId, width, height, click, dblclick, hover, brush, fill) / renderImage({ ... })
htmlOutput(outputId, container, fill, ...) / renderUI({ ... })
Reactivity
Core Reactive Primitives
server <- function(input, output, session) {
data <- reactive({
mtcars |> dplyr::filter(cyl == input$cyl)
})
count <- reactiveVal(0)
observe({
message("Data has ", nrow(data()), " rows")
})
observeEvent(input$button, {
count(count() + 1)
})
filtered <- reactive({ expensive_filter(data()) }) |>
bindEvent(input$go)
output$plot <- renderPlot({
plot(data()$mpg, data()$wt)
})
}
Reactive Rules
- reactive() for computed values. Always call with
(): data(), not data
- reactiveVal() for mutable state. Set with
val(new_value), read with val()
- reactiveValues() for named mutable state:
rv <- reactiveValues(x = 1); rv$x
- observe() for side effects that should run whenever dependencies change
- observeEvent(event, handler) for side effects triggered by a specific event
- bindEvent(x, event) to add event-gating to any reactive or render function
- req() to stop execution when inputs are not ready:
req(input$file), req(nrow(data()) > 0)
- isolate() to read a reactive value without taking a dependency
Common Pitfalls
Forgetting parentheses on reactive expressions:
output$text <- renderText(data)
output$text <- renderText(data())
Creating reactives inside observers (leaks memory):
observeEvent(input$button, {
x <- reactive({ input$n + 1 })
})
x <- reactive({ input$n + 1 })
observeEvent(input$button, {
message("x is ", x())
})
Using <<- to modify global state:
observeEvent(input$button, {
result <<- compute()
})
result <- reactiveVal()
observeEvent(input$button, {
result(compute())
})
ExtendedTask (Background Operations)
For long-running operations that should not block the UI.
library(shiny)
library(bslib)
library(promises)
library(future)
future::plan(multisession)
ui <- page_fluid(
input_task_button("go", "Run Analysis"),
textOutput("result")
)
server <- function(input, output, session) {
task <- ExtendedTask$new(function(n) {
future({
Sys.sleep(5)
mean(rnorm(n))
})
})
observeEvent(input$go, {
task$invoke(1e6)
})
output$result <- renderText({
task$result()
})
}
shinyApp(ui, server)
input_task_button() automatically disables while the task is running.
LLM Chat Apps (shinychat + ellmer)
library(shiny)
library(bslib)
library(shinychat)
library(ellmer)
ui <- page_fillable(
chat_ui("chat", fill = TRUE)
)
server <- function(input, output, session) {
chat <- chat_openai(
model = "gpt-4o",
system_prompt = "You are a helpful assistant."
)
observeEvent(input$chat_user_input, {
stream <- chat$stream(input$chat_user_input)
chat_append("chat", stream)
})
}
shinyApp(ui, server)
For Claude:
chat <- chat_claude(
model = "claude-sonnet-4-6-20250514",
system_prompt = "You are a helpful assistant."
)
Shiny Modules — Best Practices
Naming Convention
- UI function:
{name}UI(id, ...) or {name}_ui(id, ...)
- Server function:
{name}Server(id, ...) or {name}_server(id, ...)
- One module per file in
R/mod_{name}.R
Returning Values from Modules
filterServer <- function(id, data) {
moduleServer(id, function(input, output, session) {
filtered <- reactive({
data() |> dplyr::filter(species == input$species)
})
filtered
})
}
server <- function(input, output, session) {
raw_data <- reactive({ palmerpenguins::penguins })
filtered <- filterServer("filter1", raw_data)
output$table <- renderTable({
filtered()
})
}
Passing Reactives to Modules
Always pass reactives as functions (without calling them):
filterServer("filter1", data = raw_data)
filterServer("filter1", data = raw_data())
Module Communication
Modules communicate through their return values and arguments — never through global state.
server <- function(input, output, session) {
selected <- selectorServer("selector")
details <- detailServer("detail", selected)
summaryServer("summary", selected, details)
}
Testing with shinytest2
library(shinytest2)
test_that("app works", {
app <- AppDriver$new(app_dir = ".", name = "myapp")
app$set_inputs(n = 50)
app$expect_values()
app$click("go")
app$wait_for_idle()
output <- app$get_value(output = "result")
expect_true(is.character(output))
app$expect_screenshot()
})
Testing Modules with testServer
testServer(counterServer, {
expect_equal(session$returned(), 0)
session$setInputs(button = 1)
expect_equal(session$returned(), 1)
session$setInputs(button = 2)
expect_equal(session$returned(), 2)
})
Deployment
shinyapps.io
rsconnect::deployApp(appDir = ".")
Posit Connect
rsconnect::deployApp(
appDir = ".",
server = "connect.example.com",
account = "username"
)
Docker
FROM rocker/shiny:4.5.0
RUN install2.r --error bslib dplyr ggplot2
COPY . /srv/shiny-server/myapp/
EXPOSE 3838
CMD ["/usr/bin/shiny-server"]
Running Locally for Development
shiny::runApp(port = 3838, launch.browser = TRUE)
Theming with bslib
library(bslib)
my_theme <- bs_theme(
version = 5,
preset = "shiny",
bg = "#FFFFFF",
fg = "#333333",
primary = "#0062cc",
base_font = font_google("Open Sans"),
heading_font = font_google("Poppins")
)
ui <- page_sidebar(
theme = my_theme,
)
For brand-consistent theming, use _brand.yml (see brand-yml skill).
Additional Reference