# UI
ds_toggle_group(
"view_mode",
tags$button(class = "ds-button", `data-variant` = "secondary",
`aria-pressed` = "true", value = "list", "List"),
tags$button(class = "ds-button", `data-variant` = "secondary",
`aria-pressed` = "false", value = "grid", "Grid"),
tags$button(class = "ds-button", `data-variant` = "secondary",
`aria-pressed` = "false", value = "map", "Map")
)
# Server
observeEvent(input$view_mode, {
# input$view_mode is "list", "grid", or "map"
})Two kinds of components
shinyds components fall into two categories that require different approaches to Shiny reactivity.
Standard input bindings
Most components use a Shiny.InputBinding registered in ds-bindings.js. You use them exactly like native Shiny inputs:
| Component | Function(s) |
input$id value |
|---|---|---|
| Button | ds_button(inputId=) |
click count (integer) |
| Text input | ds_input() |
character string |
| Textarea | ds_textarea() |
character string |
| Checkbox | ds_checkbox() |
TRUE / FALSE
|
| Radio | ds_radio() |
selected value string |
| Select | ds_select() |
selected value string |
| Search | ds_search() |
character string |
| Suggestion | ds_suggestion() |
selected value string |
| Tabs | ds_tabs() |
selected tab value string |
| Pagination | ds_pagination() |
current page integer |
Behaviour-only module components
The Designsystemet JavaScript bundle also includes modules that enhance native HTML elements rather than defining custom elements. The affected components are:
| Component | Function(s) | HTML element |
|---|---|---|
| Toggle group | ds_toggle_group() |
<div> with buttons |
| Fieldset | ds_fieldset() |
<fieldset> |
| Details | ds_details() |
<details> |
| Dialog | ds_dialog() |
<dialog> |
| Popover | ds_popover() |
<div popover> |
These modules take over the element’s behaviour for accessibility purposes (focus management, ARIA attributes, keyboard navigation). Registering a Shiny.InputBinding on the same element creates a conflict — the binding and the module fight over the element’s state.
Do not use Shiny.InputBinding for these components. Use Shiny.setInputValue() from a plain JavaScript event listener instead.
The Shiny.setInputValue() pattern
Shiny.setInputValue(id, value) pushes a value directly to the Shiny server without needing a binding on the element. The server receives it via input$id and you react to it with observeEvent().
ds_toggle_group() uses this pattern out of the box — it generates the script block for you:
For the other behaviour-only components you attach your own listener.
Details / accordion
React to open/close events:
# UI
ds_details(
id = "my_details",
summary = "Click to expand",
ds_paragraph("Hidden content revealed on open.")
)
tags$script(HTML("
document.getElementById('my_details').addEventListener('toggle', function(e) {
Shiny.setInputValue('my_details_open', e.target.open, {priority: 'event'});
});
"))
# Server
observeEvent(input$my_details_open, {
if (isTRUE(input$my_details_open)) {
# user expanded the panel
}
}){priority: 'event'} ensures the value fires even when it hasn’t changed (e.g. opening, closing, and reopening without navigating away).
Dialog
ds_dialog() must be present in the UI. Open and close it from the server using show_ds_dialog() and hide_ds_dialog() — no JavaScript required:
# UI
ds_button("Delete item", inputId = "open_confirm", variant = "primary")
ds_dialog(
id = "confirm-dialog",
ds_heading("Confirm deletion", level = 2, size = "md"),
ds_paragraph("This action cannot be undone."),
tags$div(
style = "display:flex; gap:0.75rem; margin-top:1rem;",
ds_button("Delete", inputId = "btn_confirm", variant = "primary"),
ds_button("Cancel", inputId = "btn_cancel", variant = "secondary")
)
)
# Server
observeEvent(input$open_confirm, {
show_ds_dialog("confirm-dialog")
})
observeEvent(input$btn_cancel, {
hide_ds_dialog("confirm-dialog")
})
observeEvent(input$btn_confirm, {
hide_ds_dialog("confirm-dialog")
# perform deletion
})Reacting to close events from within the dialog
If you need to detect which button closed the dialog (e.g. Escape key vs Cancel vs Confirm), attach a close event listener and use returnValue:
tags$script(HTML("
document.getElementById('confirm-dialog').addEventListener('close', function(e) {
Shiny.setInputValue('confirm_result', e.target.returnValue, {priority: 'event'});
});
"))Pass a return value when closing: this.closest('dialog').close('confirm'). returnValue will be "confirm", "cancel", or "" (Escape key).
Popover
Detect when a popover is shown or hidden:
# UI
ds_button("Info", inputId = "info-btn", `popovertarget` = "info-pop")
ds_popover(
id = "info-pop",
popover = NA,
ds_paragraph("Contextual help text.")
)
tags$script(HTML("
var pop = document.getElementById('info-pop');
pop.addEventListener('toggle', function(e) {
Shiny.setInputValue('info_pop_open', e.newState === 'open', {priority: 'event'});
});
"))
# Server
observeEvent(input$info_pop_open, {
if (isTRUE(input$info_pop_open)) {
# log that user opened the popover, lazy-load content, etc.
}
})Fieldset
React when a checkbox or radio inside a fieldset changes, reporting the full set of checked values:
# UI
ds_fieldset(
id = "notif-fieldset",
legend = "Notification preferences",
ds_checkbox("notif_email", label = "Email"),
ds_checkbox("notif_sms", label = "SMS"),
ds_checkbox("notif_push", label = "Push")
)
tags$script(HTML("
document.getElementById('notif-fieldset').addEventListener('change', function(e) {
var checked = Array.from(
e.currentTarget.querySelectorAll('input[type=checkbox]:checked')
).map(function(el) { return el.id; });
Shiny.setInputValue('notif_prefs', checked);
});
"))
# Server
observeEvent(input$notif_prefs, {
# input$notif_prefs is a character vector of checked checkbox IDs
})Dropdown
ds_dropdown() combines a trigger element and a content panel but has no built-in Shiny reactivity. To react to the dropdown opening or closing, listen for a click on the trigger and track state yourself:
# UI
ds_dropdown(
trigger = ds_button("Options", inputId = "btn_options", variant = "secondary"),
ds_list(
ds_list_item(ds_link("Edit", href = "#")),
ds_list_item(ds_link("Delete", href = "#"))
)
)
tags$script(HTML("
(function() {
var open = false;
document.getElementById('btn_options').addEventListener('click', function() {
open = !open;
Shiny.setInputValue('options_open', open, {priority: 'event'});
});
})();
"))
# Server
observeEvent(input$options_open, {
if (isTRUE(input$options_open)) {
# dropdown was opened — lazy-load data, log analytics, etc.
}
})If you only need to react to which menu item was chosen, it is often simpler to give each item a ds_button() with its own inputId and handle them individually with observeEvent(), without tracking open/close state at all.
Phantom input suppression
The Designsystemet JavaScript bundle’s useId utility auto-generates IDs like :ds:1, :ds:2, … for child elements that have no id attribute (e.g. <legend> inside <fieldset>). Shiny picks these up as phantom input names and produces errors:
No handler registered for type :ds:1
key must not be "" or NA
Two guards prevent this:
-
R/zzz.R— registers a pass-through handler for the"ds"input type so Shiny does not error on type lookup. -
inst/www/js/ds-bindings.js— ashiny:inputchangedlistener that callspreventDefault()on any input whose name starts with:, blocking phantom inputs before they reach the server.
Both guards are always active. You do not need to add anything to your app. If you add a new behaviour-only module component and see this error, check whether the module assigns :ds:* IDs to elements that an existing binding might pick up.
Summary
| Component | Approach | Notes |
|---|---|---|
ds_toggle_group() |
Shiny.setInputValue() built in |
script generated by the R function |
ds_details() |
toggle event → setInputValue
|
use {priority:'event'}
|
ds_dialog() |
show_ds_dialog() / hide_ds_dialog()
|
preferred; JS listener only needed for returnValue
|
ds_popover() |
toggle event → setInputValue
|
e.newState === 'open' |
ds_fieldset() |
change event → setInputValue
|
collect checked inputs manually |
ds_dropdown() |
click on trigger → setInputValue
|
track open/close state manually |