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.

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.

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>. To make chips reactive, attach a click listener and call Shiny.setInputValue() — the same pattern used for ds_toggle_group() in the Reactivity Patterns vignette applies here.

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

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