Getting Started with shinyds

Introduction

shinyds provides R wrappers for Designsystemet, the Norwegian government’s design system. It lets you build Shiny applications that follow Norwegian public sector design guidelines using familiar ds_* functions.

Components come from two upstream sources:

Installation

# install.packages("remotes")
remotes::install_github("your-org/shinyds")

Minimal app

Every app needs use_designsystemet() in the UI. It loads the CSS and JavaScript bundles and activates the design token colour scheme.

library(shiny)
library(bslib)
library(shinyds)

ui <- bslib::page_fluid(
  use_designsystemet(),

  ds_heading("Hello Designsystemet!", level = 1),
  ds_paragraph("A Shiny app using Norwegian government design components."),

  ds_field(
    ds_label("Your name", `for` = "name"),
    ds_input("name", placeholder = "Enter your name")
  ),

  ds_button("Submit", inputId = "submit", variant = "primary"),

  verbatimTextOutput("out")
)

server <- function(input, output, session) {
  output$out <- renderPrint(list(name = input$name, clicks = input$submit))
}

shinyApp(ui, server)

bslib::page_fluid() is the recommended page function — it provides Bootstrap 5 and works cleanly alongside Designsystemet’s own CSS tokens.

Theming

Use ds_theme() alongside use_designsystemet() to override colors, typography, and spacing without writing CSS manually:

ui <- bslib::page_fluid(
  use_designsystemet(),
  ds_theme(
    color          = "brand1",       # global color context
    border_radius  = "2px",          # tighter corners
    button_padding = "0.3rem 0.8rem" # compact buttons
  ),
  # ... components
)

Pass arbitrary Designsystemet CSS token overrides via ...:

ds_theme(`--ds-border-width-focus` = "3px")

Available color contexts: "default", "brand1", "brand2", "neutral", "success", "warning", "danger", "info".

Form controls

Button

# label first, then inputId
ds_button("Click me",  inputId = "btn1", variant = "primary")
ds_button("Secondary", inputId = "btn2", variant = "secondary")
ds_button("Tertiary",  inputId = "btn3", variant = "tertiary")

# sizes
ds_button("Small",  size = "sm")
ds_button("Medium", size = "md")
ds_button("Large",  size = "lg")

# states
ds_button("Disabled", variant = "primary", disabled = TRUE)
ds_button("Loading",  variant = "secondary", loading = TRUE)

input$btn1 starts at 0 and increments by 1 on each click.

ds_action_button(inputId, label, ...) is a drop-in replacement for shiny::actionButton() with the same argument order:

# equivalent to shiny::actionButton("go", "Submit")
ds_action_button("go", "Submit", variant = "primary")

Text inputs

ds_field(
  ds_label("Email", `for` = "email"),
  ds_input("email", type = "email", placeholder = "you@example.com"),
  ds_validation_message("Must be a valid email address", variant = "error")
)

ds_field(
  ds_label("Message", `for` = "msg"),
  ds_textarea("msg", placeholder = "Type here…", rows = 4)
)

Wrap ds_input() / ds_textarea() in ds_field() with a ds_label() — this is the standard Designsystemet pattern and ensures correct label association and spacing.

Checkbox and radio

ds_checkbox("agree", label = "I accept the terms")

# Radio buttons — group them with a shared name
ds_radio("opt_a", label = "Option A", value = "a", name = "opts", checked = TRUE)
ds_radio("opt_b", label = "Option B", value = "b", name = "opts")

input$agree returns TRUE / FALSE. Radio inputs are individual elements; read any one of the group’s inputId values to get the selected value.

Select

ds_field(
  ds_label("Country", `for` = "country"),
  ds_select("country",
    choices  = c("Norway" = "no", "Sweden" = "se", "Denmark" = "dk"),
    selected = "no"
  )
)

input$country returns the selected option’s value string.

Fieldset

Group related controls under a shared legend:

ds_fieldset(
  legend = "Notification preferences",
  ds_checkbox("notif_email", label = "Email"),
  ds_checkbox("notif_sms",   label = "SMS"),
  ds_checkbox("notif_push",  label = "Push")
)

Note: ds_fieldset() is backed by a behaviour-only JavaScript module. See the Reactivity Patterns vignette if you need to react to fieldset-level events.

Search and suggestion

# Search input — input$q returns the current text
ds_field(
  ds_label("Search", `for` = "q"),
  ds_search("q", placeholder = "Search…")
)

# Autocomplete suggestion — input$fruit returns the selected value
ds_field(
  ds_label("Fruit", `for` = "fruit"),
  ds_suggestion("fruit",
    choices     = c("Apple", "Banana", "Cherry"),
    placeholder = "Start typing…"
  )
)

ds_suggestion() includes built-in keyboard navigation and filtering. If you need a fully custom autocomplete — for example, with server-side filtering or custom option rendering — use ds_combobox() instead. It provides the CSS container only; keyboard navigation and dropdown behaviour are the caller’s responsibility.

Form validation

Use ds_validation_message() on individual fields to show inline errors, and ds_error_summary() at the top of the form to collect all errors in one place. Render both conditionally from the server:

ui <- bslib::page_fluid(
  use_designsystemet(),
  uiOutput("error_summary"),
  ds_field(
    ds_label("Email", `for` = "email"),
    ds_input("email", placeholder = "you@example.com"),
    uiOutput("email_error")
  ),
  ds_button("Submit", inputId = "submit", variant = "primary")
)

server <- function(input, output, session) {
  observeEvent(input$submit, {
    errors <- list()

    if (!nzchar(trimws(input$email %||% ""))) {
      errors$email <- "Email is required"
    } else if (!grepl("@", input$email, fixed = TRUE)) {
      errors$email <- "Must be a valid email address"
    }

    output$email_error <- renderUI({
      if (!is.null(errors$email))
        ds_validation_message(errors$email, variant = "error")
    })

    output$error_summary <- renderUI({
      if (length(errors) > 0)
        ds_error_summary(
          heading = "Please fix the following errors",
          tags$li(ds_link(errors$email, href = "#email"))
        )
    })
  })
}

Link each item in ds_error_summary() to the corresponding field’s id so keyboard users can jump directly to the problem field.

Typography

# Headings — level sets the HTML element (h1–h6), size sets the visual token
ds_heading("Page Title",    level = 1, size = "2xl")
ds_heading("Section Title", level = 2, size = "lg")
ds_heading("Card Title",    level = 3, size = "md")

ds_paragraph("Body copy.", size = "md")
ds_paragraph("Small caption.", size = "sm")

ds_link("Designsystemet", href = "https://designsystemet.no")

ds_list(
  ds_list_item("First"),
  ds_list_item("Second"),
  ds_list_item("Third"),
  ordered = TRUE
)

Layout

Cards

ds_card(
  ds_card_block(
    ds_heading("Card Title", level = 3, size = "sm"),
    ds_paragraph("Card content.")
  )
)

ds_card(variant = "tinted",
  ds_card_block("Highlighted content")
)

Tables

ds_table(
  ds_thead(
    ds_tr(ds_th("Name"), ds_th("Role"), ds_th("Status"))
  ),
  ds_tbody(
    ds_tr(ds_td("Alice"), ds_td("Developer"),
          ds_td(ds_tag("Active", color = "success"))),
    ds_tr(ds_td("Bob"),   ds_td("Designer"),
          ds_td(ds_tag("Active", color = "success")))
  )
)

Tabs (web component)

ds_tabs("my_tabs",
  ds_tablist(
    ds_tab("Overview",  value = "overview",  selected = TRUE),
    ds_tab("Details",   value = "details")
  ),
  ds_tabpanel(value = "overview",
    ds_paragraph("Overview content.")
  ),
  ds_tabpanel(value = "details",
    ds_paragraph("Details content.")
  )
)

# In server: input$my_tabs returns the selected tab value string

Pagination (web component)

ds_pagination("pager", current = 1, total = 10)

# In server: input$pager returns the current page number (integer)

Show the current location within a multi-level structure:

ds_breadcrumbs(
  tags$ol(
    tags$li(ds_link("Home",     href = "/")),
    tags$li(ds_link("Reports",  href = "/reports")),
    tags$li(tags$span("Annual summary"))  # current page — plain text, no link
  )
)

The last item should be plain text rather than a link, since it represents the current page.

Place a skip link at the very top of the page so keyboard users can jump past navigation directly to the main content:

ui <- bslib::page_fluid(
  use_designsystemet(),
  ds_skip_link("Skip to main content", href = "#main"),
  # ... navigation ...
  tags$main(id = "main",
    # ... page content ...
  )
)

ds_skip_link() renders as a visually hidden link that becomes visible when it receives keyboard focus. It should be the first focusable element on the page.

Feedback components

ds_alert("Informational message.", variant = "info")
ds_alert("Operation succeeded.",   variant = "success")
ds_alert("Review before saving.",  variant = "warning")
ds_alert("Something went wrong.",  variant = "danger")

ds_spinner(title = "Loading…", size = "md")

ds_skeleton(variant = "text",      width = "100%")
ds_skeleton(variant = "circle",    width = "48px", height = "48px")
ds_skeleton(variant = "rectangle", width = "200px", height = "80px")

ds_badge_position(
  ds_button("Inbox", variant = "secondary"),
  ds_badge(count = 4, color = "danger")
)

Display components

Avatar

Display user initials or a profile image:

# Initials
ds_avatar("AB", size = "sm")
ds_avatar("CD", size = "md")
ds_avatar("EF", size = "lg")

# Image
ds_avatar(
  tags$img(src = "profile.jpg", alt = "Alice B."),
  size = "lg"
)

# Group several avatars in a stack
ds_avatar_stack(
  ds_avatar("AB"),
  ds_avatar("CD"),
  ds_avatar("EF"),
  ds_avatar("GH")
)

Chip

A toggleable filter chip with an aria-pressed state:

ds_chip("React", selected = TRUE)
ds_chip("Vue")
ds_chip("Angular")
ds_chip("Svelte")

ds_chip() renders as a <button> and is display-only on its own.

For reactive multi-select filtering, wrap chips in ds_chip_group(). Selected chip values are reported to Shiny as a character vector via input[[inputId]]:

# UI
ds_chip_group(
  "languages",
  ds_chip("R",      value = "r",      selected = TRUE),
  ds_chip("Python", value = "python"),
  ds_chip("Julia",  value = "julia")
)

# Server
observeEvent(input$languages, {
  cat("Selected:", paste(input$languages, collapse = ", "), "\n")
})

Each chip needs a value attribute (passed via ...) that becomes the reported value. If value is omitted, the chip’s text content is used instead. Disabled chips are excluded from click handling automatically.

Tooltip

ds_tooltip() wraps any element and attaches a tooltip via data-tooltip attributes. Unlike other components it does not create a new element — it modifies the element you pass in:

ds_tooltip(
  ds_button("Delete", variant = "danger"),
  text      = "Permanently removes the record",
  placement = "top"    # "top", "bottom", "left", "right"
)

ds_tooltip(
  ds_badge(count = 12),
  text = "Unread notifications"
)

Updating inputs from the server

Several inputs support programmatic updates:

server <- function(input, output, session) {
  observeEvent(input$reset, {
    update_ds_input(session,    "name",    value = "")
    update_ds_checkbox(session, "agree",   value = FALSE)
    update_ds_select(session,   "country", value = "no")
    update_ds_tabs(session,     "my_tabs", selected = "overview")
    update_ds_pagination(session, "pager", current = 1)
  })
}

Opening and closing dialogs

ds_dialog() must be placed in the UI. Open and close it from the server:

# UI
ds_button("Open", inputId = "open_dlg")
ds_dialog(
  id = "my-dialog",
  ds_paragraph("Are you sure?"),
  ds_button("Confirm", inputId = "btn_confirm"),
  ds_button("Cancel",  inputId = "btn_cancel", variant = "secondary")
)

# Server
observeEvent(input$open_dlg,   show_ds_dialog("my-dialog"))
observeEvent(input$btn_cancel, hide_ds_dialog("my-dialog"))
observeEvent(input$btn_confirm, {
  hide_ds_dialog("my-dialog")
  # act on confirmation
})

Using bslib layout primitives

Because shinyds components are plain HTML tags, they compose naturally with bslib layout functions:

ui <- bslib::page_fluid(
  use_designsystemet(),

  bslib::layout_sidebar(
    sidebar = bslib::sidebar(
      title    = "Controls",
      position = "right",
      width    = 280,
      verbatimTextOutput("values")
    ),

    # Main content
    ds_tabs("tabs",
      ds_tablist(
        ds_tab("Form",   value = "form",   selected = TRUE),
        ds_tab("Result", value = "result")
      ),
      ds_tabpanel(value = "form",
        ds_field(ds_label("Name", `for` = "nm"), ds_input("nm")),
        ds_button("Submit", inputId = "go")
      ),
      ds_tabpanel(value = "result",
        verbatimTextOutput("out")
      )
    )
  )
)

Use bslib::layout_columns() for equal-width column grids, and bslib::layout_column_wrap(width = "220px") for auto-fitting responsive grids.

Running the example apps

# Minimal form example
shiny::runApp(system.file("examples/basic", package = "shinyds"))

# Data visualisation with tabs and layout_sidebar
shiny::runApp(system.file("examples/faithful", package = "shinyds"))

# Full component reference
shiny::runApp(system.file("examples/showcase", package = "shinyds"))

Next steps

mirror server hosted at Truenetwork, Russian Federation.