diff --git a/NAMESPACE b/NAMESPACE
index 5a9b78e3d..a02ae45e6 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -165,6 +165,7 @@ export(toggle_switch)
export(toggle_tooltip)
export(toolbar)
export(toolbar_divider)
+export(toolbar_download_button)
export(toolbar_input_button)
export(toolbar_input_select)
export(toolbar_spacer)
@@ -174,6 +175,7 @@ export(update_popover)
export(update_submit_textarea)
export(update_switch)
export(update_task_button)
+export(update_toolbar_download_button)
export(update_toolbar_input_button)
export(update_toolbar_input_select)
export(update_tooltip)
diff --git a/NEWS.md b/NEWS.md
index 3585168ca..04cb28fa1 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,9 @@
# bslib (development version)
+## New features
+
+* Added `toolbar_download_button()` and `update_toolbar_download_button()` for adding a download button to a `toolbar()`, styled consistently with other toolbar inputs. (#1292)
+
## Improvements and bug fixes
* Fixed `toolbar_input_button()` alignment and spacing issues. (#1290)
diff --git a/R/sysdata.rda b/R/sysdata.rda
index 70283fe7c..3f76075dd 100644
Binary files a/R/sysdata.rda and b/R/sysdata.rda differ
diff --git a/R/toolbar.R b/R/toolbar.R
index d36243ca5..c8e0d3358 100644
--- a/R/toolbar.R
+++ b/R/toolbar.R
@@ -440,7 +440,7 @@ toolbar_input_button <- function(
icon = icon_elem,
disabled = disabled,
class = "bslib-toolbar-input-button btn-sm",
- class = if (!border) "border-0" else "border-1",
+ class = if (!border) "border-0" else "border",
"data-type" = btn_type,
"aria-labelledby" = label_id,
...
@@ -960,3 +960,149 @@ toolbar_divider <- function(..., width = NULL, gap = NULL) {
toolbar_spacer <- function() {
div(class = "bslib-toolbar-spacer")
}
+
+#' Toolbar Download Button
+#'
+#' @description
+#' A download button designed to fit well in small places such as in a [toolbar()].
+#'
+#' @param outputId The download output ID (connects to [shiny::downloadHandler()] in server).
+#' @param label The button label. By default, `label` is not shown but is used by
+#' `tooltip`. Set `show_label = TRUE` to show the label.
+#' @param icon An icon. Defaults to `shiny::icon("download")`.
+#' @param show_label Whether to show the label text. If `FALSE` (the default),
+#' only the icon is shown. If `TRUE`, the label text is shown alongside the icon.
+#' @param tooltip Tooltip text to display when hovering. Can be:
+#' * `TRUE` (default when `show_label = FALSE`) - shows tooltip with `label` text
+#' * `FALSE` (default when `show_label = TRUE`) - no tooltip
+#' * A character string - shows tooltip with custom text
+#' @param ... Additional attributes passed to the `` tag.
+#' @param disabled If `TRUE`, the button will not be clickable. Since `` tags
+#' have no native disabled attribute, this adds `class="disabled"`,
+#' `aria-disabled="true"`, and `tabindex="-1"`.
+#' @param border Whether to show a border around the button.
+#'
+#' @return Returns a download button suitable for use in a toolbar.
+#'
+#' @examplesIf rlang::is_interactive()
+#' # Download button in a card toolbar
+#' card(
+#' card_header(
+#' "Flower Data",
+#' toolbar(
+#' align = "right",
+#' toolbar_download_button("download_data", label = "Download")
+#' )
+#' )
+#' )
+#'
+#' @family toolbar components
+#' @export
+toolbar_download_button <- function(
+ outputId,
+ label = "Download",
+ icon = shiny::icon("download"),
+ show_label = FALSE,
+ tooltip = !show_label,
+ ...,
+ disabled = FALSE,
+ border = FALSE
+) {
+ btn_type <-
+ if (is.null(icon)) {
+ if (!show_label) {
+ rlang::abort(
+ "If `show_label` is FALSE, `icon` must be provided."
+ )
+ }
+ "label"
+ } else {
+ if (show_label) "both" else "icon"
+ }
+
+ # Validate that label has text for accessibility
+ label_text <- paste(unlist(find_characters(label)), collapse = " ")
+ # Verifies the label contains non-empty text
+ if (!nzchar(trimws(label_text))) {
+ warning(
+ "Consider providing a non-empty string label for accessibility."
+ )
+ }
+
+ label_id <- paste0("btn-label-", p_randomInt(1000, 10000))
+
+ # We hide the label visually if `!show_label` but keep the label field for
+ # use with `aria-labelledby`. This ensures that ARIA will always use the
+ # label text.
+ label_elem <- span(
+ class = "action-label",
+ span(
+ id = label_id,
+ class = "bslib-toolbar-label",
+ hidden = if (!show_label) NA else NULL,
+ label
+ )
+ )
+
+ # And we wrap the icon to ensure that it is always treated as decorative
+ icon_elem <- span(
+ class = "action-icon",
+ span(
+ class = "bslib-toolbar-icon",
+ `aria-hidden` = "true",
+ style = "pointer-events: none",
+ icon
+ )
+ )
+
+ button <- tags$a(
+ id = outputId,
+ class = "bslib-toolbar-download-button btn btn-sm shiny-download-link",
+ class = if (!border) "border-0" else "border",
+ class = if (disabled) "disabled",
+ href = "",
+ target = "_blank",
+ rel = "noopener noreferrer",
+ download = NA,
+ `data-type` = btn_type,
+ `aria-labelledby` = label_id,
+ `aria-disabled` = if (disabled) "true" else NULL,
+ tabindex = if (disabled) "-1" else NULL,
+ icon_elem,
+ label_elem,
+ ...
+ )
+
+ # If tooltip is literally TRUE, use the label as the tooltip text.
+ if (isTRUE(tooltip)) {
+ tooltip <- label
+ }
+ if (isFALSE(tooltip)) {
+ tooltip <- NULL
+ }
+ if (!is.null(tooltip)) {
+ # Default placement is "bottom" for the toolbar case because otherwise the
+ # tooltip ends up covering the neighboring buttons in the header/footer.
+ button <- tooltip(
+ button,
+ tooltip,
+ id = sprintf("%s_tooltip", outputId),
+ placement = "bottom"
+ )
+ }
+
+ button
+}
+
+#' @param session A Shiny session object.
+#'
+#' @describeIn toolbar_download_button Update a toolbar download button.
+#' @export
+update_toolbar_download_button <- function(
+ outputId,
+ disabled = NULL,
+ session = get_current_session()
+) {
+ message <- dropNulls(list(disabled = disabled))
+ session$sendInputMessage(outputId, message)
+}
diff --git a/design/2026-03-16-toolbar-download-button-design.md b/design/2026-03-16-toolbar-download-button-design.md
new file mode 100644
index 000000000..303cffe97
--- /dev/null
+++ b/design/2026-03-16-toolbar-download-button-design.md
@@ -0,0 +1,219 @@
+# toolbar_download_button() Design Spec
+
+**Date:** 2026-03-16
+**Issue:** https://github.com/rstudio/bslib/issues/1292
+**Status:** Approved
+
+## Summary
+
+Add `toolbar_download_button()` to bslib's toolbar component family, providing a download button styled consistently with other toolbar inputs for use in card headers, footers, and other toolbar contexts.
+
+## Motivation
+
+Users want an elegant way to add download functionality to card toolbars. Currently, using Shiny's `downloadButton()` in a toolbar requires manual styling workarounds (adding classes, inline styles, and ARIA attributes). A dedicated `toolbar_download_button()` would provide first-class support with proper styling and accessibility out of the box.
+
+## Design
+
+### Function Signature
+
+```r
+toolbar_download_button <- function(
+ outputId,
+ label = "Download",
+ icon = shiny::icon("download"),
+ show_label = FALSE,
+ tooltip = !show_label,
+ ...,
+ disabled = FALSE,
+ border = FALSE
+)
+```
+
+#### Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `outputId` | character | required | The download output ID (connects to `downloadHandler` in server) |
+| `label` | character/tag | `"Download"` | Button label text; used for tooltip when `show_label = FALSE` |
+| `icon` | icon | `shiny::icon("download")` | Icon to display; can be overridden with custom icon |
+| `show_label` | logical | `FALSE` | Whether to show label text (icon-only by default) |
+| `tooltip` | logical/character | `!show_label` | Tooltip behavior; `TRUE` shows label as tooltip, `FALSE` disables, or custom string |
+| `...` | | | Additional HTML attributes passed to the `` tag |
+| `disabled` | logical | `FALSE` | Initial disabled state. Since `` tags have no native `disabled` attribute, this adds `class="disabled"`, `aria-disabled="true"`, and `tabindex="-1"` |
+| `border` | logical | `FALSE` | Show border around button |
+
+#### Return Value
+
+An `` tag suitable for use in a `toolbar()`, styled to match other toolbar components.
+
+### HTML Structure
+
+The generated HTML follows the same accessibility patterns as `toolbar_input_button()`:
+
+```html
+
+
+ Download
+
+
+
+
+
+
+
+
+ Download
+
+
+
+
+```
+
+Key structural decisions:
+- **Built directly with `tags$a()`** - NOT wrapping `shiny::downloadButton()` or `shiny::actionButton()`. We construct the `` tag from scratch to have full control over structure.
+- Uses `bslib-toolbar-download-button` class (new) plus `shiny-download-link` (Shiny's download machinery)
+- Nested `span.action-icon > span.bslib-toolbar-icon` and `span.action-label > span.bslib-toolbar-label` - These wrappers are added manually to match the structure that `actionButton()` generates for `toolbar_input_button`, ensuring CSS rules apply consistently to both. The `aria-hidden` and `style` attributes go on the inner `.bslib-toolbar-icon` span, matching the pattern from `toolbar_input_button()` (see R/toolbar.R:430-435).
+- Same `data-type` attribute pattern (`"icon"`, `"label"`, `"both"`) for CSS targeting
+- `aria-labelledby` points to the label span for screen reader support
+- Tooltip wrapper follows same pattern as `toolbar_input_button`
+
+### Update Function
+
+```r
+update_toolbar_download_button <- function(
+ outputId,
+ disabled = NULL,
+ session = get_current_session()
+)
+```
+
+Supports updating only the `disabled` state (not label/icon), as download buttons rarely need dynamic updates beyond enable/disable.
+
+#### Implementation Pattern
+
+Follows the same pattern as `toolbar_input_button` and bslib's tooltip/popover components:
+
+**R side:**
+```r
+update_toolbar_download_button <- function(
+ outputId,
+ disabled = NULL,
+ session = get_current_session()
+) {
+ message <- dropNulls(list(disabled = disabled))
+ session$sendInputMessage(outputId, message)
+}
+```
+
+**TypeScript side:** A minimal input binding that handles `receiveMessage`:
+
+```typescript
+class BslibToolbarDownloadButtonBinding extends InputBinding {
+ find(scope: HTMLElement) {
+ return $(scope).find(".bslib-toolbar-download-button");
+ }
+
+ getValue(el: HTMLElement) {
+ return null; // Not used as input
+ }
+
+ receiveMessage(el: HTMLElement, message: { disabled?: boolean }) {
+ if (hasDefinedProperty(message, "disabled")) {
+ if (message.disabled) {
+ el.classList.add("disabled");
+ el.setAttribute("aria-disabled", "true");
+ el.setAttribute("tabindex", "-1");
+ } else {
+ el.classList.remove("disabled");
+ el.removeAttribute("aria-disabled");
+ el.removeAttribute("tabindex");
+ }
+ }
+ }
+}
+
+registerBinding(BslibToolbarDownloadButtonBinding, "toolbar-download-button");
+```
+
+**Why this works:** This pattern (input binding for a non-input component) is established precedent in bslib. Both `BslibTooltip` and `BslibPopover` set `static isShinyInput = true` to enable `sendInputMessage()` for server-to-client updates, even though they're not traditional inputs. Similarly, `toolbar_input_button` uses a standalone `InputBinding` class (see `toolbarInputButton.ts`). We follow the same standalone binding approach here.
+
+## Usage Example
+
+```r
+library(shiny)
+library(bslib)
+
+ui <- page_fluid(
+ card(
+ card_header(
+ "Flower Data",
+ toolbar(
+ align = "right",
+ toolbar_download_button("download_data", label = "Download")
+ )
+ ),
+ card_body(
+ reactable::reactable(iris)
+ )
+ )
+)
+
+server <- function(input, output, session) {
+ output$download_data <- downloadHandler(
+ filename = function() {
+ paste("iris-", Sys.Date(), ".csv", sep = "")
+ },
+ content = function(file) {
+ write.csv(iris, file, row.names = FALSE)
+ }
+ )
+}
+
+shinyApp(ui, server)
+```
+
+## Files to Modify/Create
+
+| File | Action | Description |
+|------|--------|-------------|
+| `R/toolbar.R` | Modify | Add `toolbar_download_button()` and `update_toolbar_download_button()` |
+| `srcts/src/components/toolbarDownloadButton.ts` | Create | New input binding for update handling |
+| `inst/components/scss/toolbar.scss` | Modify | Update `data-type` attribute selectors (lines ~71, ~78, ~85) to include `.bslib-toolbar-download-button` alongside `.bslib-toolbar-input-button`. Base button styles use Bootstrap `.btn` classes. |
+| `tests/testthat/test-toolbar.R` | Modify | Add tests for new functions |
+| `man/toolbar_download_button.Rd` | Generated | Roxygen-generated documentation |
+| `NAMESPACE` | Generated | Export new functions |
+
+## Testing Plan
+
+**Unit tests (automated):**
+1. **Snapshot test** - Verify HTML structure matches expected output
+2. **Parameter validation** - Test that `outputId` is required, label defaults work
+3. **Tooltip behavior** - Test tooltip is added when `show_label = FALSE`, not added when `show_label = TRUE`
+4. **Border/disabled states** - Test class application
+5. **Icon override** - Test custom icon replaces default
+
+**Integration test (manual):**
+6. **Download functionality** - Manual verification that download works in a Shiny app. Automated download testing is complex due to browser security restrictions; manual testing via the example app (`shiny::runExample("toolbar", package = "bslib")`) is sufficient.
+
+## Accessibility
+
+- `aria-labelledby` points to label span for screen reader support
+- Tooltip provides accessible name when label is visually hidden
+- Disabled state uses both `disabled` class and `aria-disabled="true"`
+- Icon wrapped with `aria-hidden="true"` to prevent duplicate announcements
+
+## Alternatives Considered
+
+**Custom Download Input Binding:** Create a `