Two kinds of components
shinyds components fall into two categories that require different approaches to Shiny reactivity.
Most components use a Shiny.InputBinding registered in ds-bindings.js. You use them exactly like native Shiny inputs:
| 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:
| 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.
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:
# 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"
})
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
Track whether the dialog is open and which button was used to close it:
# UI
tags$button(
class = "ds-button", `data-variant` = "primary",
onclick = "document.getElementById('confirm-dialog').showModal()",
"Delete item"
)
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;",
tags$button(
id = "dialog-confirm",
class = "ds-button", `data-variant` = "primary",
onclick = "document.getElementById('confirm-dialog').close('confirm')",
"Delete"
),
tags$button(
class = "ds-button", `data-variant` = "secondary",
onclick = "document.getElementById('confirm-dialog').close('cancel')",
"Cancel"
)
)
)
tags$script(HTML("
document.getElementById('confirm-dialog').addEventListener('close', function(e) {
Shiny.setInputValue('confirm_dialog', e.target.returnValue, {priority: 'event'});
});
"))
# Server
observeEvent(input$confirm_dialog, {
if (input$confirm_dialog == 'confirm') {
# perform deletion
}
})
HTMLDialogElement.close(returnValue) sets dialog.returnValue, which the close event makes available as e.target.returnValue.
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.
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 — a shiny:inputchanged listener that calls preventDefault() 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
ds_toggle_group() |
Shiny.setInputValue() built in |
script generated by the R function |
ds_details() |
toggle event → setInputValue |
use {priority:'event'} |
ds_dialog() |
close event → setInputValue |
returnValue carries which button |
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 |