Single-Select Dropdown with glasstabs

Overview

glassSelect() is an animated single-select dropdown for Shiny.

For filters that accept several values, see the multi-select guide.

It behaves like a standard Shiny input: input$<inputId> stores the selected value as a single character string, or NULL when nothing is selected.

It supports:

Basic usage

library(shiny)
library(glasstabs)

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

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

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

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

Initial selection

# No selection (default)
glassSelect("f", fruits)

# Pre-select one value
glassSelect("f", fruits, selected = "banana")

Empty-string values are ordinary values

An empty string is a valid choice value, distinct from nothing selected: glassSelect("status", c("All statuses" = "", "Active" = "active"), selected = "") starts on “All statuses” and reports "", while clearing the field reports NULL. The same holds for updateGlassSelect(..., selected = "").

Optional label

glassSelect("f", fruits, label = "Choose a fruit")

Selection styles

glassSelect() supports three selection marker styles:

glassSelect("f1", fruits, check_style = "checkbox")
glassSelect("f2", fruits, check_style = "check-only")
glassSelect("f3", fruits, check_style = "filled")

Corner shape

By default the trigger and dropdown use the signature rounded glass corners. Pass shape = "square" for crisp, selectize-style corners so the widget sits flush next to native selectizeInput() controls without looking out of place:

# Rounded (default)
glassSelect("region_rounded", fruits, shape = "rounded")

# Square - matches selectizeInput()
glassSelect("region_square", fruits, shape = "square")

The shape can also be switched at runtime with updateGlassSelect(session, "region_square", shape = "square"). Here is the complete side-by-side layout with a native Shiny control:

fluidRow(
  column(
    6,
    selectizeInput("native_region", "Native", choices = fruits)
  ),
  column(
    6,
    glassSelect(
      "glass_region",
      fruits,
      label = "glasstabs",
      shape = "square",
      width = "100%"
    )
  )
)

Width, grouped choices, and disabled options

For parity with native selectizeInput(), glassSelect() accepts a width, grouped choices as a named list, and disabled state:

The open dropdown follows the trigger width. Longer labels wrap within that space rather than making a short field unexpectedly wider.

# Fixed / fluid width
glassSelect("region_w", fruits, width = "240px")

# Grouped choices (selectInput()-style named list)
glassSelect("food", list(
  Fruit = c(Apple = "apple", Banana = "banana"),
  Veg   = c(Carrot = "carrot", Pea = "pea")
))

# Disable the whole widget, or specific options
glassSelect("region_d", fruits, disabled = TRUE)
glassSelect("region_dc", fruits, disabled_choices = "banana")

disabled and disabled_choices can also be toggled at runtime with updateGlassSelect().

Long selected labels

The default overflow_tooltip = "auto" adds a label preview only when the selected trigger text is actually shortened with an ellipsis. It appears on pointer hover or keyboard focus and is capped for exceptionally large labels; the complete accessible name remains available. Touch users can open the dropdown to read the label wrapped inside its matching-width panel. Truncated options in the open dropdown also reveal a preview on hover or keyboard navigation.

long_industries <- c(
  "Renewable energy generation, storage, and grid-scale distribution networks" = "energy",
  Healthcare = "healthcare"
)

glassSelect(
  "industry",
  long_industries,
  selected = "energy",
  width = "240px",
  overflow_tooltip = "auto"
)

Use overflow_tooltip = "never" when the visual explanation is unnecessary. The complete accessible label remains on the trigger in either mode.

Searchable and clearable

Search is enabled by default. Use searchable = "auto" when short lists should stay simple and search should appear only after the choices reach a threshold. You can also enable a clear control.

glassSelect(
  "f",
  fruits,
  searchable = TRUE,
  clearable = TRUE
)

The Clear button is keyboard-reachable too: with the dropdown open, Arrow Down past the last option moves focus to it (Arrow Up returns to the options), and Enter or Space clears the selection. Tab and Escape close the panel as usual.

For example, five categories do not need a search field when the threshold is six:

glassSelect(
  "category",
  c(Alpha = "alpha", Beta = "beta", Gamma = "gamma", Delta = "delta", Epsilon = "epsilon"),
  searchable = "auto",
  search_threshold = 6
)

The same settings can change at runtime:

updateGlassSelect(
  session,
  "category",
  searchable = "auto",
  search_threshold = 5
)

Field and motion styling can be adjusted without changing the input value:

glassSelect(
  "f",
  fruits,
  theme = "auto",
  dark_selector = "body.app-dark",
  tongue = FALSE,
  label_bold = TRUE,
  label_size = "14px",
  search_focus_ring = FALSE
)

tongue = TRUE uses the attached glass unfurl. With FALSE, the panel is detached and uses a short, native-feeling fade. The quiet search focus style is the default; set search_focus_ring = TRUE for the accent outline. When an app uses a custom dark-mode class instead of Bootstrap’s data-bs-theme, pass that class through dark_selector while using theme = "auto".

To always hide the search box:

glassSelect(
  "f",
  fruits,
  searchable = FALSE
)

Server-side search for large choice sets

For hundreds or thousands of choices, set server = TRUE in the UI and register glassSelectServer() in the server function. The browser renders only an initial slice of choices and asks Shiny for matching choices as the user types.

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

ui <- fluidPage(
  useGlassTabs(),
  glassSelect(
    "pick",
    many_choices,
    selected = "value-1500",
    clearable = TRUE,
    server = TRUE,
    server_limit = 30
  ),
  verbatimTextOutput("out")
)

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

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

This pattern also works when the control is created dynamically with renderUI(). Keep the glassSelectServer() call in the server function for the same inputId.

The two limits are independent — see the multi-select guide’s server-search section for the full table: server_limit bounds the initial slice, while the helper’s limit bounds each search’s matches. Results are bounded, not paginated: each search returns the first N matches, with no next-page cursor. And as with the multi-select, server = TRUE with search disabled is unsupported: without a search input the user cannot reach choices outside the initial slice. The explicit “All” option works in server mode (see below); it sits first in the initial slice and is restored across search responses.

Explicit “All” option

You can prepend an explicit “All” option:

glassSelect(
  "f",
  fruits,
  include_all = TRUE,
  all_choice_label = "All fruits",
  all_choice_value = "__all__"
)

This is useful when you want the UI to distinguish between: - no selection - a deliberate “All” selection

The option is off by default (include_all = FALSE). When on, the widget prepends one row with label all_choice_label (default "All categories") and value all_choice_value (default "__all__"); choosing it returns that value, not the full choice list, so the app decides what “All” means downstream. The row is a client-side affordance, not a data row: it survives choices updates (the row and an active “All” selection are both preserved across updateGlassSelect() calls) and it works in server mode too, where it sits first in the initial slice and is restored across search responses. The search threshold counts real choices only — the All row is excluded from it.

Server-side access

Read the current value directly from input$<inputId>:

server <- function(input, output, session) {
  observe({
  message("Selected: ", if (is.null(input$pick)) "NULL" else input$pick)
})
}

Reactive helper

If you want a small convenience wrapper, use glassSelectValue():

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

  observe({
    print(pick())
  })
}

Updating choices and selection from the server

updateGlassSelect() can update: - available choices - current selection - selection marker style

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

It follows Shiny-style update semantics:

ui <- fluidPage(
  useGlassTabs(),
  actionButton("subset", "Keep first 2 fruits"),
  actionButton("banana", "Select banana"),
  actionButton("clear", "Clear"),
  actionButton("filled", "Use filled style"),
  glassSelect("pick", fruits, clearable = TRUE),
  verbatimTextOutput("out")
)

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

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

  observeEvent(input$banana, {
    updateGlassSelect(
      session,
      "pick",
      selected = "banana"
    )
  })

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

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

When choices is updated without selected, the widget keeps the current value if it still exists in the new set of choices. If it no longer exists, the selection clears and the value becomes NULL — there is no stale-value state, and no separate “invalid” marker. To keep a selection that is temporarily absent instead, do not remove it from the choice set; the multi-select updater’s drop_invalid = FALSE has no single-select equivalent. Two more update rules: passing more than one value to selected is an error (use the multi-select updater for multiple values), and the label cannot be updated — there is no label argument on updateGlassSelect(), so a label change means recreating the widget with renderUI().

Theming

Built-in presets

glassSelect("f", fruits, theme = "dark")
glassSelect("f", fruits, theme = "light")
glassSelect("f", fruits, theme = "auto")

Use theme = "auto" inside Bootstrap 5 / bslib apps to follow data-bs-theme light and dark modes without server code.

Custom theme with glass_select_theme()

glassSelect(
  "f",
  fruits,
  theme = glass_select_theme(
    mode = "dark",
    accent_color = "#f59e0b"
  )
)

glassSelect(
  "f",
  fruits,
  theme = glass_select_theme(
    bg_color = "#1a0a2e",
    border_color = "#a855f7",
    text_color = "#ede9fe",
    accent_color = "#a855f7"
  )
)


#other themes
glassSelect(
  "f",
  fruits,
  theme = glass_select_theme(
    mode = "light",
    accent_color = "#2563eb"
  )
)

#more themes
glassSelect(
  "f",
  fruits,
  theme = glass_select_theme(
    mode = "light",
    bg_color = "#ffffff",
    border_color = "rgba(0,0,0,0.15)",
    text_color = "#111111",
    accent_color = "#111111",
    label_color = "#111111"
  )
)

Standalone usage

glassSelect() can be used without the tab widget. You only need useGlassTabs() to load the package CSS and JavaScript assets:

ui <- fluidPage(
  useGlassTabs(),
  glassSelect("region", c(North = "North", South = "South", East = "East")),
  verbatimTextOutput("out")
)

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

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

mirror server hosted at Truenetwork, Russian Federation.