Multi-Select with glasstabs

Why multi-select is the centerpiece

Most Shiny dashboards are, at heart, a filter problem: pick a set of categories, regions, or products, and everything downstream — tables, plots, summaries — follows that set. glassMultiSelect() is the widget built for exactly that job. It is a searchable, themeable, animated multi-select dropdown that behaves like a standard Shiny input while adding the pieces a filter UI usually needs bolted on afterwards: a compact trigger summary, removable tag pills, a select-all row, server-side search for large choice sets, and transactional server updates.

If you only learn one glasstabs widget, learn this one. The single-select (glassSelect()) shares its dropdown engine, themes, and animations, so much of this transfers—but the multi-select is where the package’s filtering story lives. See the single-select guide when one value is the right contract.

A complete app in one page

library(shiny)
library(glasstabs)

fruits <- c(Apple = "apple",
            Banana = "banana",
            Cherry = "cherry",
            Mango = "mango",
            Peach = "peach")

ui <- fluidPage(
  useGlassTabs(),
  glassMultiSelect("pick", fruits),
  glassFilterTags("pick"),
  verbatimTextOutput("out")
)

server <- function(input, output, session) {
  output$out <- renderPrint(input$pick)
}

if (interactive()) shinyApp(ui, server)

Two things to notice. First, useGlassTabs() is called once in the UI: it loads the package CSS and JavaScript. The multi-select does not need a tab layout — it works standalone in any Shiny page. Second, the widget is an ordinary Shiny input: input$pick is a character vector of the selected values, and it updates as the user selects and deselects options.

What the widget produces

Input Type Value
input$<inputId> character vector Currently selected values
input$<inputId>_style character Active checkbox style
input$<inputId>_open logical Whether the dropdown is open
input$<inputId>_ready logical Whether the widget has finished initializing

input$<inputId>_ready becomes TRUE once the widget’s JavaScript has booted and synced its initial state. Gate observers that depend on the widget being initialized on it, rather than on session start:

server <- function(input, output, session) {
  observeEvent(input$pick_ready, {
    # Safe to read input$pick and drive downstream outputs here.
  }, ignoreInit = FALSE)
}

The selection model: all, some, or none

The default surprises people coming from selectizeInput(), so say it plainly: when selected = NULL (the default), every choice starts selected. That is the filter-friendly default — an untouched filter means “everything”, not “nothing”.

# All selected (default)
glassMultiSelect("f", fruits)

# A subset pre-selected
glassMultiSelect("f", fruits, selected = c("apple", "cherry"))

# Nothing pre-selected — the trigger shows the placeholder instead
glassMultiSelect("f", fruits, selected = character(0))

Two labels control the trigger text at the extremes. placeholder (default "Filter by Category") shows when nothing is selected; all_label (default "All categories") shows when everything is selected. Set them to match the domain — "Filter by region" and "All regions" read better than the generics in a real app.

How a partial selection is shown

selection_display controls the trigger when some, but not all, options are selected:

# "auto" — the familiar single-label / "Multiple selection" behavior
glassMultiSelect("f", fruits, selection_display = "auto")

# "3 selected" — compact, always the same width
glassMultiSelect("f", fruits, selection_display = "count")

# First labels, then "+N" — the dashboard default
glassMultiSelect("f", fruits,
  selection_display = "summary",
  selection_max_items = 2
)

# Every selected label, comma-separated
glassMultiSelect("f", fruits, selection_display = "labels")

selection_max_items (default 2) sets how many labels summary shows before the +N remainder. For a filter bar with several multi-selects side by side, count or summary keeps triggers from jumping around as selections change.

Check styles, and the style switcher

Three indicator styles are available via check_style:

# Bordered box + animated tick (default)
glassMultiSelect("f", fruits, check_style = "checkbox")

# Tick only — minimal, no box border
glassMultiSelect("f", fruits, check_style = "check-only")

# Solid coloured box
glassMultiSelect("f", fruits, check_style = "filled")

By default a Check / Box / Fill switcher row sits inside the dropdown so users can change the style at runtime; the active style is readable at input$<inputId>_style. Hide it with show_style_switcher = FALSE to lock the style silently.

Filled rows: one accent, or one hue per option

fill_colors controls the filled style, and the default matters:

# One theme accent for every selected row (the default)
glassMultiSelect("f", fruits,
  check_style = "filled",
  fill_colors = "single"
)

# Per-option hues — categorical colour per choice
glassMultiSelect("f", fruits,
  check_style = "filled",
  fill_colors = "multi"
)

With fill_colors = "multi", hues are auto-assigned evenly around the colour wheel. Override them with a named integer vector of HSL hue angles (0–360); the hues argument only applies when fill_colors = "multi":

glassMultiSelect("f", fruits,
  check_style = "filled",
  fill_colors = "multi",
  hues = c(apple = 0, banana = 50, cherry = 340, mango = 30, peach = 20)
)

Search: always, automatic, or never

Search is shown by default. Three modes cover the real cases:

# Always show the search box (the default)
glassMultiSelect("f", fruits, searchable = TRUE)

# Hide it entirely — right for a small, fixed list
glassMultiSelect("f", fruits, searchable = FALSE)

# Show it only once the list is big enough to need it
glassMultiSelect("f", fruits,
  searchable = "auto",
  search_threshold = 12
)

With searchable = "auto", the search box appears only when the choice count reaches search_threshold (default 15). Five categories stay uncluttered; fifty get a search box without any extra code. The threshold is a choice count, not a character count, and it can be changed at runtime with updateGlassMultiSelect().

Two smaller search controls: no_matches_text (default "No matches") is the empty-result message, and dropdown_max_height (default "260px") caps the scrolling option area. search_focus_ring = TRUE opts into an accent outline when the search field is focused; the default FALSE uses a quieter background and border change.

Long labels, wrapping, and tooltips

The open dropdown always matches the trigger width — it never stretches a short field to fit a long label. Longer labels wrap inside that shared width instead, and the trigger itself uses a stable compact width (160px) unless you pass width explicitly (fixed or fluid widths are supported and stay capped by the containing layout):

glassMultiSelect("f", fruits, width = "100%")
glassMultiSelect("f", fruits, width = "240px")

A wrapped option is still fully readable. With the default overflow_tooltip = "auto", hovering a truncated option—or reaching it with keyboard navigation—reveals a capped text preview; only one option tooltip appears at a time. The complete option text remains in the DOM and accessible name. The trigger itself does not combine every selection into one visual tooltip—for a multi-select, that combined tooltip would just be noise. On touch devices, the wrapped option text inside the open dropdown is the fallback. Set overflow_tooltip = "never" to disable option tooltips.

glassMultiSelect(
  "long_filters",
  c(
    "Commercial real estate development and property management services" = "property",
    Healthcare = "healthcare"
  ),
  selected = "property",
  width = "240px",
  selection_display = "summary",
  overflow_tooltip = "auto"
)

Select all, clear all, and the chrome rows

Three chrome rows sit inside the dropdown, each independently toggleable (all default TRUE):

glassMultiSelect("f", fruits,
  show_style_switcher = FALSE,   # hide the Check / Box / Fill row
  show_select_all     = FALSE,   # hide the "Select all" row
  show_clear_all      = FALSE    # hide the "Clear all" footer link
)

The select-all row acts only on enabled, visible options: it selects everything currently matching the search (skipping disabled rows), or clears them if all are already selected, and it shows an indeterminate state for a partial selection. In server mode the row acts on the currently loaded page: options that have not arrived from the server yet are not part of the toggle set, and glassMultiSelectServer(..., disabled_choices) marks values disabled as they arrive in search responses. “Clear all” clears the whole selection. Note there is no clearable argument on glassMultiSelect() — clearing is show_clear_all, which is TRUE by default. (clearable belongs to the single-select and does not exist here; passing it would be an unused-argument error.)

Disabled options — and what survives a choices update

Disable the whole widget, or individual options:

glassMultiSelect("filters_d", fruits, disabled = TRUE)
glassMultiSelect("filters_dc", fruits, disabled_choices = "banana")

Disabled rows cannot be selected, and “Select all” skips them. A disabled choice is also excluded from the default all-selected state: with disabled_choices = "banana" and no explicit selected, banana starts unselected while everything else starts selected.

One update subtlety is worth knowing: when choices are replaced without passing disabled_choices, the widget keeps the previous disabled rows. To change the disabled set, pass it explicitly in the same update (see “Updating from the server” below).

Grouped choices

Grouped choices use the selectInput()-style named list:

glassMultiSelect("food", list(
  Fruit = c(Apple = "apple", Banana = "banana"),
  Veg   = c(Carrot = "carrot", Pea = "pea")
))

As with selectInput(), a one-element named list such as list(Group = "x") is treated as a flat, ungrouped choice.

Tag pills with glassFilterTags()

Place a glassFilterTags() container anywhere on the page to show the active selections as removable tag pills. The JavaScript engine keeps them in sync automatically — clicking the x on a tag deselects that option:

ui <- fluidPage(
  useGlassTabs(),
  glassMultiSelect("cat", fruits, show_style_switcher = FALSE),
  glassFilterTags("cat")    # can be placed anywhere, even in a different tab pane
)

You can place several glassFilterTags("cat") containers for the same filter — all stay in sync. This is the pattern that makes the multi-select feel like a filter system rather than a single input: the dropdown lives in a sidebar, the pills live next to the results, and both reflect the same input$cat.

Reading values on the server

Read the selection directly from input$<inputId> and the style from input$<inputId>_style:

server <- function(input, output, session) {
  observe({
    message("Selected: ", paste(input$pick, collapse = ", "))
    message("Style: ", input$pick_style)
  })
}

The glassMultiSelectValue() helper

For filtering code, glassMultiSelectValue() wraps the raw input with one genuinely useful idea: an empty selection is ambiguous. Does empty mean “match nothing” or “match everything”? The raw input stays honest (character(0)), and resolved() answers the question separately:

server <- function(input, output, session) {
  ms <- glassMultiSelectValue(input, "pick")

  observe({
    message("Selected: ", paste(ms$selected(), collapse = ", "))
    message("Style: ", ms$style())
  })
}
rows <- data.frame(
  fruit = c("apple", "banana", "cherry"),
  n = c(12, 7, 9)
)

server <- function(input, output, session) {
  ms <- glassMultiSelectValue(
    input,
    "pick",
    choices = fruits,
    empty_behavior = "all"
  )

  filtered <- reactive({
    rows[rows$fruit %in% ms$resolved(), , drop = FALSE]
  })
}

empty_behavior has three modes: "none" (the default — resolved() is character(0)), "all" (empty resolves to every choice value, so an untouched filter matches everything), and "null" (empty resolves to NULL, for downstream code that expects it). empty_behavior = "all" requires choices — pass the same choices used by glassMultiSelect(), or a reactive returning them. is_empty() is also available for UI messages or validation.

Understanding selection behavior: All, Some, and None

The multi-select default surprises developers who expect an empty starting state: selected = NULL selects every enabled choice initially. Disabled choices are never in that default, and they are never part of the Select-all set either — the Select-all row and its indicator act on enabled, visible choices only (see the server-mode note below for how that scope works with bounded results). Three states, three meanings:

State Widget value What the app usually does with it
All choices selected Character vector of all selected values Show all matching records
Some choices selected Character vector of those values Show matching records only
Nothing selected character(0) Show no records — or, if the app chooses, no filter

The last row is where bugs hide. An app that treats empty as “no filter” is valid when chosen deliberately — but if the example is copied without that choice, pressing Clear all makes every record reappear even though the dropdown visibly holds no selection. Decide which meaning empty has, and write it down in the filter:

Approach A: empty means no records. Note that req() on an empty selection also stops the calculation, so the explicit version below is the one to copy:

rows <- data.frame(
  fruit = c("apple", "banana", "cherry"),
  n = c(12, 7, 9)
)

filter_none <- function(selected) {
  if (is.null(selected) || length(selected) == 0L) {
    return(rows[0, , drop = FALSE])
  }
  rows[rows$fruit %in% selected, , drop = FALSE]
}

stopifnot(nrow(filter_none(character(0))) == 0L)
stopifnot(nrow(filter_none("apple")) == 1L)

Approach B: empty means all records:

filter_all <- function(selected) {
  if (is.null(selected) || length(selected) == 0L) {
    return(rows)
  }
  rows[rows$fruit %in% selected, , drop = FALSE]
}

stopifnot(nrow(filter_all(character(0))) == 3L)
stopifnot(nrow(filter_all("banana")) == 1L)

The same decision applies at three moments: on initial load (default selected = NULL starts at All, while selected = character(0) starts at None), after Clear all (the widget returns character(0)), and after a reset (whatever the reset writes back — see the linked-filters article for the three reset meanings). The glassMultiSelectValue(..., empty_behavior = "all") helper changes how the app resolves an empty selection; it does not change what the widget returns, which is always character(0) when empty.

Server-side search for large choice sets

For hundreds or thousands of choices, keep the full vector on the server and send a small bounded set of matches to the browser. Set server = TRUE in the UI and register glassMultiSelectServer() in the server function:

many_choices <- stats::setNames(
  sprintf("value-%04d", 1:2000),
  sprintf("Choice %04d", 1:2000)
)

ui <- fluidPage(
  useGlassTabs(),
  glassMultiSelect(
    "pick",
    many_choices,
    server = TRUE,
    server_limit = 30,
    show_style_switcher = FALSE
  ),
  verbatimTextOutput("out")
)

server <- function(input, output, session) {
  glassMultiSelectServer("pick", many_choices, session = session, limit = 30)
  output$out <- renderPrint(length(input$pick))
}

if (interactive()) shinyApp(ui, server)

Two independent limits bound what the browser renders. They are set in different places and nothing ties them together — setting one does not change the other:

Setting Where it lives What it bounds
server_limit (default 50) glassMultiSelect() / glassSelect() Choices rendered initially, excluding explicitly included selections
limit (default 50) glassMultiSelectServer() / glassSelectServer() Matches returned for each search

So server_limit = 25 with limit = 100 shows 25 choices on open and up to 100 per search. server_min_chars (default 0) sets how many characters must be typed before server-side matching filters choices, and ignore_case = TRUE (the default in glassMultiSelectServer()) matches labels and values case-insensitively. Two details that make this safe: explicitly selected values that fall outside the initial slice are still rendered as extra rows, so they stay visible and checked; and the control keeps the selected values on the client while only the current bounded set of matches is rendered.

Results are bounded, not paginated: the helper returns the first N matches for each search. There is no next-page cursor and no built-in Load more mechanism, so “page” is the wrong word for what comes back.

In server mode the Select-all row acts on the currently loaded page: with 20,000 facilities and 30 matches on screen, Select all selects those 30, not all 20,000. The full selection stays represented in Shiny — selected values persist on the client across searches — but only the current matches are rendered. The same scope rule applies to the indicator: it reports the enabled, visible set, not the whole dataset.

Performance and limitations

Server mode reduces what is rendered in the browser, not what R holds: the complete choices vector is still supplied to the constructor and to the server helper, so this is not database-backed lazy search. And because the default multi-select selection contains all enabled choice values, a large selection still creates a large input payload even with bounded DOM rendering — pass selected = character(0) when the app does not need everything selected up front.

One unsupported configuration: server = TRUE with search disabled (searchable = FALSE or "never"). Without a search input the user cannot query beyond the initial slice, so choices outside it are unreachable through the dropdown. Keep search enabled in server mode.

This pattern also suits controls inserted by renderUI(): register glassMultiSelectServer() once for the same inputId, and the newly rendered control receives search results through Shiny.

Updating from the server

updateGlassMultiSelect() updates choices, selection, style, shape, disabled state, search visibility and threshold, summary mode, and dropdown height — in one transaction:

ui <- fluidPage(
  useGlassTabs(),
  actionButton("subset", "Keep first 3 fruits"),
  actionButton("pick2", "Select 2 fruits"),
  actionButton("clear", "Clear"),
  actionButton("fill", "Switch to filled style"),
  glassMultiSelect("pick", fruits),
  verbatimTextOutput("out")
)

server <- function(input, output, session) {
  output$out <- renderPrint(input$pick)

  observeEvent(input$subset, {
    updateGlassMultiSelect(session, "pick", choices = fruits[1:3])
  })

  observeEvent(input$pick2, {
    updateGlassMultiSelect(
      session, "pick",
      selected = c("apple", "cherry")
    )
  })

  observeEvent(input$clear, {
    updateGlassMultiSelect(session, "pick", selected = character(0))
  })

  observeEvent(input$fill, {
    updateGlassMultiSelect(session, "pick", check_style = "filled")
  })
}

if (interactive()) shinyApp(ui, server)

The NULL semantics follow Shiny conventions and are worth memorizing:

Three arguments control the transaction itself:

server <- function(input, output, session) {
  observeEvent(input$region, {
    updateGlassMultiSelect(
      session,
      "pick",
      choices = fruits[fruits %in% c("apple", "banana", "cherry")],
      preserve_selection = TRUE,
      drop_invalid = TRUE,
      searchable = "auto",
      notify = "changed"
    )
  })
}

Closing dropdowns and dynamic layouts

For dynamic layouts, modals, sidebars, or tab changes, close an open dropdown explicitly from the server:

server <- function(input, output, session) {
  observeEvent(input$change_layout, {
    closeGlassMultiSelect(session, "pick")
  })
}

Use closeAllGlassSelects(session) when a layout change should close any open glasstabs select dropdown. Dropdowns also dismiss themselves when a full-screen overlay appears — a modal, a spinner, or a hand-rolled overlay — so an open panel never floats above a dialog.

Theming

Built-in presets

glassMultiSelect("f", fruits, theme = "dark")   # default
glassMultiSelect("f", fruits, theme = "light")  # white panel, dark text
glassMultiSelect("f", fruits, theme = "auto")   # follows Bootstrap/bslib color mode

theme = "auto" follows the Bootstrap/bslib color mode: it uses the light preset by default and switches to the dark preset when an ancestor carries data-bs-theme = "dark". For apps that announce dark mode some other way — a body.dark-mode class in bs4Dash, for example — pass that selector as dark_selector, and the widget emits an extra scoped style block that reverts colors to the dark defaults whenever the selector is active:

glassMultiSelect("f", fruits,
  theme = "auto",
  dark_selector = "body.dark-mode"
)

Custom themes with glass_select_theme()

Supply only the colors you want to change; everything else inherits from the mode base preset:

# One field — accent colour only
glassMultiSelect("f", fruits,
  theme = glass_select_theme(accent_color = "#f59e0b")
)

# Two fields
glassMultiSelect("f", fruits,
  theme = glass_select_theme(
    text_color   = "#1e293b",
    accent_color = "#2563eb"
  )
)

# Four commonly customized fields
glassMultiSelect("f", fruits,
  theme = glass_select_theme(
    bg_color     = "#1a0a2e",
    border_color = "#a855f7",
    text_color   = "#ede9fe",
    accent_color = "#a855f7"
  )
)
Argument What it controls
bg_color Dropdown panel and trigger button background
border_color Border colour
text_color Main text and label colour
accent_color Tick marks, badge, checked highlights, Clear all link
focus_ring Keyboard focus indicator
label_color Field-title colour
mode Theme mode metadata ("dark" or "light")

Presentation: tongue, labels, corners, width

glassMultiSelect("f", fruits,
  tongue = TRUE,              # attached unfurl (default); FALSE = detached short fade
  label_bold = TRUE,          # semibold field label, matching native inputs
  label_size = "14px",        # keeps the title distinct from the options
  search_focus_ring = FALSE,  # quiet search focus (default); TRUE = accent outline
  shape = "square"            # crisp corners beside native selectize inputs
)

tongue = TRUE (the default) attaches the dropdown flush to the trigger and unfurls it with the signature animation; FALSE detaches the panel with a short fade, which sits more naturally beside native Shiny inputs. Either way the panel matches the trigger width, long labels wrap, and reduced-motion preferences collapse the transitions.

Placement: standalone, in tabs, in dashboards

The multi-select is fully independent of the tab widget — only useGlassTabs() (for the CSS and JavaScript) is required. It works inside a glassTabsUI() layout, in a plain fluidPage(), in a bslib page with theme = "auto", and in a bs4Dash dashboard with dark_selector for the dashboard’s dark-mode class. Each instance is scoped to its own inputId and works independently; when one dropdown is open it floats above the others:

ui <- fluidPage(
  useGlassTabs(),
  glassMultiSelect("a", c(X = "x", Y = "y", Z = "z")),
  glassMultiSelect("b", c(P = "p", Q = "q", R = "r"),
                   check_style = "filled",
                   show_style_switcher = FALSE)
)

Keyboard and pointer interaction

A worked dashboard: linked filters

This is the pattern most apps end up with — two filters, one driving the other, tags beside the results, and a reset that clears both:

library(shiny)
library(glasstabs)

sales <- data.frame(
  region  = c("North", "North", "South", "South", "East", "West"),
  product = c("Alpha", "Beta", "Alpha", "Gamma", "Beta", "Alpha"),
  revenue = c(42000, 31000, 27000, 55000, 38000, 61000)
)

products_by_region <- list(
  North = c(Alpha = "Alpha", Beta = "Beta"),
  South = c(Alpha = "Alpha", Gamma = "Gamma"),
  East  = c(Beta = "Beta"),
  West  = c(Alpha = "Alpha")
)

ui <- fluidPage(
  useGlassTabs(),
  glassMultiSelect("region",
    c(North = "North", South = "South", East = "East", West = "West"),
    show_style_switcher = FALSE
  ),
  glassMultiSelect("product",
    c(Alpha = "Alpha", Beta = "Beta", Gamma = "Gamma"),
    show_style_switcher = FALSE,
    check_style = "check-only"
  ),
  glassFilterTags("region"),
  glassFilterTags("product"),
  actionButton("reset", "Reset filters"),
  tableOutput("tbl")
)

server <- function(input, output, session) {
  fallback <- function(x, default) {
    if (is.null(x) || length(x) == 0) default else x
  }

  # Region drives the product list; keep what still applies.
  observeEvent(input$region, {
    available <- unique(unlist(
      products_by_region[fallback(input$region, names(products_by_region))],
      use.names = FALSE
    ))
    updateGlassMultiSelect(
      session, "product",
      choices = stats::setNames(available, available),
      preserve_selection = TRUE,
      drop_invalid = TRUE,
      notify = "changed"
    )
  }, ignoreNULL = FALSE)

  observeEvent(input$reset, {
    updateGlassMultiSelect(session, "region", selected = character(0))
    updateGlassMultiSelect(session, "product", selected = character(0))
  })

  filtered <- reactive({
    sales[
      sales$region %in% fallback(input$region, unique(sales$region)) &
      sales$product %in% fallback(input$product, unique(sales$product)),
    ]
  })

  output$tbl <- renderTable(filtered())
}

if (interactive()) shinyApp(ui, server)

Note the two update choices that make linked filters behave: the product update keeps the current selection where it still applies (preserve_selection = TRUE) and drops values the new region no longer offers (drop_invalid = TRUE), and notify = "changed" means the table only recomputes when the effective selection actually moved.

Compared with selectizeInput(multiple = TRUE)

If you are migrating a native multi-select, this is what changes:

Gotchas, collected

mirror server hosted at Truenetwork, Russian Federation.