---
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):

- [Shiny Basics](https://shiny.posit.co/r/getstarted/)
- [bslib Dashboards](https://rstudio.github.io/bslib/articles/dashboards/)
- [bslib Cards](https://rstudio.github.io/bslib/articles/cards/)
- [bslib Value Boxes](https://rstudio.github.io/bslib/articles/value-boxes/)
- [Shiny Modules](https://shiny.posit.co/r/articles/improve/modules/)
- [Reactivity Overview](https://shiny.posit.co/r/articles/build/reactivity-overview/)
- [ExtendedTask](https://shiny.posit.co/r/reference/shiny/latest/ExtendedTask.html)
- [shinychat](https://posit-dev.github.io/shinychat/)
- [shinytest2](https://rstudio.github.io/shinytest2/)
- [Deployment](https://shiny.posit.co/r/articles/share/shinyapps/)

## Core Patterns

**Single-file app** (`app.R`): simplest structure, everything in one file.

```r
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.

```r
# R/mod_counter.R

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:

```r
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.

```r
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.

```r
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).

```r
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.

```r
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.

```r
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.

```r
value_box(
  title = "Total Sales",
  value = textOutput("total"),
  showcase = bsicons::bs_icon("currency-dollar"),
  theme = "primary"
)
```

### `accordion(..., id, open, multiple, class)`
Collapsible sections.

```r
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()`.

```r
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

```r
server <- function(input, output, session) {
  # Reactive expression: cached, lazy, re-runs when dependencies change
  data <- reactive({
    mtcars |> dplyr::filter(cyl == input$cyl)
  })

  # Reactive value: manually set, like a variable
  count <- reactiveVal(0)

  # Observer: side effects, runs eagerly when dependencies change
  observe({
    message("Data has ", nrow(data()), " rows")
  })

  # observeEvent: side effects triggered by specific events
  observeEvent(input$button, {
    count(count() + 1)
  })

  # bindEvent: make any reactive respond only to specific events
  filtered <- reactive({ expensive_filter(data()) }) |>
    bindEvent(input$go)

  # Output
  output$plot <- renderPlot({
    plot(data()$mpg, data()$wt)
  })
}
```

### Reactive Rules

1. **reactive()** for computed values. Always call with `()`: `data()`, not `data`
2. **reactiveVal()** for mutable state. Set with `val(new_value)`, read with `val()`
3. **reactiveValues()** for named mutable state: `rv <- reactiveValues(x = 1); rv$x`
4. **observe()** for side effects that should run whenever dependencies change
5. **observeEvent(event, handler)** for side effects triggered by a specific event
6. **bindEvent(x, event)** to add event-gating to any reactive or render function
7. **req()** to stop execution when inputs are not ready: `req(input$file)`, `req(nrow(data()) > 0)`
8. **isolate()** to read a reactive value without taking a dependency

### Common Pitfalls

**Forgetting parentheses on reactive expressions:**
```r
# WRONG: passes the reactive object, not its value
output$text <- renderText(data)

# RIGHT: calls the reactive to get current value
output$text <- renderText(data())
```

**Creating reactives inside observers (leaks memory):**
```r
# WRONG: creates a new reactive every time button is clicked
observeEvent(input$button, {
  x <- reactive({ input$n + 1 })  # memory leak!
})

# RIGHT: define reactives at the top level
x <- reactive({ input$n + 1 })
observeEvent(input$button, {
  message("x is ", x())
})
```

**Using `<<-` to modify global state:**
```r
# WRONG: global assignment breaks reactivity
observeEvent(input$button, {
  result <<- compute()  # not reactive!
})

# RIGHT: use reactiveVal
result <- reactiveVal()
observeEvent(input$button, {
  result(compute())
})
```

## ExtendedTask (Background Operations)

For long-running operations that should not block the UI.

```r
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)  # simulate long computation
      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)

```r
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:
```r
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

```r
filterServer <- function(id, data) {
  moduleServer(id, function(input, output, session) {
    filtered <- reactive({
      data() |> dplyr::filter(species == input$species)
    })

    # Return reactive for parent to use
    filtered
  })
}

# In parent server:
server <- function(input, output, session) {
  raw_data <- reactive({ palmerpenguins::penguins })
  filtered <- filterServer("filter1", raw_data)

  output$table <- renderTable({
    filtered()  # use returned reactive
  })
}
```

### Passing Reactives to Modules

Always pass reactives as functions (without calling them):

```r
# RIGHT: pass the reactive itself
filterServer("filter1", data = raw_data)

# WRONG: pass the current value (won't update)
filterServer("filter1", data = raw_data())
```

### Module Communication

Modules communicate through their return values and arguments — never through global state.

```r
# Parent orchestrates module communication
server <- function(input, output, session) {
  selected <- selectorServer("selector")     # returns reactive
  details <- detailServer("detail", selected) # receives reactive
  summaryServer("summary", selected, details) # receives both
}
```

## Testing with shinytest2

```r
library(shinytest2)

test_that("app works", {
  app <- AppDriver$new(app_dir = ".", name = "myapp")

  # Set input and wait
  app$set_inputs(n = 50)
  app$expect_values()

  # Click button
  app$click("go")
  app$wait_for_idle()

  # Check output
  output <- app$get_value(output = "result")
  expect_true(is.character(output))

  # Snapshot test (visual regression)
  app$expect_screenshot()
})
```

### Testing Modules with testServer

```r
testServer(counterServer, {
  # Initially zero
  expect_equal(session$returned(), 0)

  # Click button
  session$setInputs(button = 1)
  expect_equal(session$returned(), 1)

  # Click again
  session$setInputs(button = 2)
  expect_equal(session$returned(), 2)
})
```

## Deployment

### shinyapps.io
```r
rsconnect::deployApp(appDir = ".")
```

### Posit Connect
```r
rsconnect::deployApp(
  appDir = ".",
  server = "connect.example.com",
  account = "username"
)
```

### Docker
```dockerfile
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
```r
shiny::runApp(port = 3838, launch.browser = TRUE)
```

## Theming with bslib

```r
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

- [End-to-End Examples](examples.md) — complete working apps
