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:

- CSS components — plain HTML elements styled with `ds-*` CSS classes (`ds_button()`, `ds_alert()`, `ds_input()`, …)
- Web components — custom HTML elements with built-in JavaScript behaviour (`ds_tabs()`, `ds_pagination()`, `ds_suggestion()`)

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

## Navigation

### Breadcrumbs

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.

### Skip link

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

- Reactivity Patterns — how to add Shiny reactivity to behaviour-only module components (toggle group, details, dialog, popover, fieldset, dropdown): `vignette("reactivity-patterns", package = "shinyds")`
- Designsystemet documentation
- Shiny documentation