Strict, faithful Bootstrap 5.3 widgets for Shiny — with minimum deviation from Shiny itself.
bootstrict re-implements the Bootstrap 5.3 layout, content, forms and component library as Shiny UI functions.
Why
Working with an external designer that doesn’t know shiny can be complex because of two things:
- some Shiny components are not plain Bootstrap;
- some Bootstrap components are missing from Shiny.
bootstrict tries to fix this gap by giving you the whole Bootstrap 5 surface, and nothing more, meaning that you can tell a designer: “you can use anything from Bootstrap 5.3. But nothing more”.
Installation
# install.packages("pak")
pak::pak("thinkr-open/bootstrict")Bootstrap 5.3 is vendored by bootstrict itself and compiled with sass, so the version reaching the browser is fixed by this package rather than by whichever theming package happens to be installed — bootstrap_version() reports it. The markup bootstrict emits matches the runtime it ships.
The designer hand-off
Every widget mirrors the Bootstrap 5.3 HTML structure one-to-one, so a designer’s mockup (for example in Figma) and exported SASS variables drop straight into a Shiny app. Interactive components report their state to the server and can be driven from the server with update_*() helpers.
One thing falls short of that, inherited from the Shiny inputs the package delegates to: every delegated input keeps Shiny’s div.form-group.shiny-input-container wrapper. Everything else is the reference markup, and the test suite snapshots it — no third-party widget library is shipped, and a test enforces that.
The rule, when the two collide: if it is not in the Bootstrap documentation it is not in bootstrict, even where that loses a Shiny feature.
The motivating workflow: a designer works in Figma, stays strictly within the Bootstrap 5.3 docs, and exports a _variables.scss sheet.
You received a Figma mockup and the variables, and can implement this directly into shiny.
library(shiny)
library(bootstrict)
variables <- tempfile(fileext = ".scss")
writeLines(
c(
"$primary: #ff6600;",
"$border-radius:5rem;"
),
variables
)
ui <- bs_page(
theme = bootstrict_theme(
variables = variables
),
bs_container(
bs_card(
bs_card_header("Sign in"),
bs_card_body(
bs_text_input("email", "Email", placeholder = "you@example.com"),
bs_password_input("pw", "Password"),
bs_button("go", "Sign in", color = "primary")
)
),
# Declare the modal once, at the top level of the page (not inside the
# card): Bootstrap can clip or mis-position overlays nested in another
# element. The server then opens it by id (see below).
bs_modal(
"info",
"Modal body text.",
title = "Heads up"
)
)
)
server <- function(input, output, session) {
observeEvent(input$go, {
print(input$email)
print(input$pw)
show_bs_modal("info")
})
}
shinyApp(ui, server)bootstrict_theme() collects SASS variable overrides for the vendored Bootstrap tree; parse_scss_variables() turns a $name: value; sheet into the named list it takes. Inline overrides win over the file:
bootstrict_theme(
variables = "_variables.scss",
primary = "#ff6600"
)Conventions (minimum deviation from Shiny)
- Every constructor is
snake_case, prefixedbs_(no masking of Shiny). -
...works exactly like Shiny/htmltools: named args become HTML attributes, unnamed args become children. - Interactive widgets take a leading
id; their value isinput$id. -
Form inputs delegate to the matching
shiny::*Input(), so the reactive value and everyupdateXxx()keep working identically —bootstrictonly layers Bootstrap 5 markup, sizing, help text, switches, input groups and floating labels on top.
How bootstrict differs from Shiny
bootstrict stays as close to Shiny as it can, but a handful of behaviours differ on purpose (to follow native Bootstrap). If you already know Shiny, these are the things to watch for.
Overlay widgets live in the UI — they aren’t built from the server
Shiny builds modals and notifications on the server (showModal(modalDialog(...)), showNotification(...)). bootstrict follows the native Bootstrap pattern instead: the modal, toast or offcanvas is declared once in the UI with an id, and the server only opens or closes it by id.
ui <- bs_page(
bs_button(
"open",
"Open"
),
# declared in the UI
bs_modal(
"info",
"Body text",
title = "Heads up"
)
)
server <- function(input, output, session) {
observeEvent(
input$open, {
# opened by id
show_bs_modal("info")
}
)
}| Task | Shiny | bootstrict |
|---|---|---|
| Open a modal | showModal(modalDialog(...)) |
declare bs_modal("id", …), then show_bs_modal("id")
|
| Close a modal | removeModal() |
hide_bs_modal("id") |
| Notification | showNotification("…") |
bs_notify_toast("…") — the one server-built widget (builds + shows a transient toast) |
| Offcanvas / drawer | (not in Shiny) | declare bs_offcanvas("id", …), then show_bs_offcanvas("id")
|
Two consequences:
-
Place overlay widgets at the top level of the page (a direct child of
bs_page()/bs_container()), not nested inside abs_card()or other positioned element — Bootstrap can otherwise clip or mis-position them. - Every overlay reports its open state back as
input$id(TRUEwhen shown) — Shiny modals don’t. You can also open them with no server round trip using the UI triggersbs_modal_trigger(),bs_offcanvas_trigger()andbs_collapse_trigger().
Server helpers take id first and session last
Shiny’s updaters take the session first: updateTextInput(session, "id", …). Every bootstrict helper takes the id first and the session last and optional (it defaults to the current reactive domain), and ids are namespaced automatically inside modules:
update_bs_tabset("tabs", selected = "profile") # no session argument needed
show_bs_modal("info")Two kinds of inputs
-
Inputs that delegate to Shiny —
bs_text_input(),bs_numeric_input(),bs_select_input(),bs_radio_input(),bs_checkbox_input(),bs_checkbox_group_input(),bs_file_input(),bs_textarea_input(),bs_password_input(). They wrap the matchingshiny::*Input()and only restyle the markup, soinput$idand Shiny’s ownupdateXxx()keep working unchanged — useshiny::updateTextInput()etc. for these. -
Native inputs —
bs_range_input(),bs_color_input(),bs_date_input(),bs_date_range_input(),bs_radio_button_input()andbs_checkbox_button_input(). Either Shiny has no equivalent, or its equivalent is not Bootstrap markup. They ship their own bindings, so drive them withupdate_bs_range()/update_bs_color()/update_bs_date_input()/update_bs_toggle_buttons()(Shiny’supdateSliderInput()andupdateDateInput()won’t reach them).
A few specifics worth knowing:
-
bs_select_input()renders a plain Bootstrap<select>— selectize is off, so there is no search / tagging box that Shiny’sselectInput()adds by default. -
bs_range_input()is a native HTML<input type="range">, not Shiny’ssliderInput()(no ticks, animation or ion.rangeSlider features). -
bs_file_input()is the Bootstrap 5.3<input class="form-control" type="file">, so the browser draws the button and the file name — not Shiny’s “Browse” button beside a readonly text box, which is Bootstrap 3 markup. -
bs_date_input()/bs_date_range_input()are native<input type="date">fields, so the browser supplies the calendar. They do not delegate to Shiny, which would pull inbootstrap-datepicker— a third-party stylesheet whose calendar is nowhere in the Bootstrap docs. The cost is thatformat,language,weekstartanddatesdisabledare gone; drive them withupdate_bs_date_input(). - Validation feedback needs
bs_feedback(): Bootstrap only shows a message that is a sibling of the marked control, and a barebs_invalid_feedback()after an input is a sibling of Shiny’s wrapper.set_bs_validation()switches the state from the server.
bs_button() is an action button only when given an id
bs_button("go", "Go") behaves exactly like shiny::actionButton() — input$go is the click count. Called without an id it is an inert, styled button; reactivity is opt-in.
Interactive components are driven by update_bs_*()
Give a widget an id and it reports its state as input$id and takes instructions from a matching helper:
| Widget | input$id |
Server helper |
|---|---|---|
bs_accordion() |
open panel value(s) | update_bs_accordion() |
bs_tabset() |
active tab | update_bs_tabset() |
bs_carousel() |
active slide | update_bs_carousel() |
bs_collapse() |
open / closed | update_bs_collapse() |
bs_list_group() |
selected item | update_bs_list_group() |
bs_nav(), bs_navbar_nav()
|
active link | update_bs_nav() |
bs_pagination() |
active page | update_bs_pagination() |
bs_dropdown(), bs_nav_dropdown()
|
open / closed |
show_bs_dropdown() … |
bs_alert() |
still on the page | close_bs_alert() |
bs_progress() |
— | update_bs_progress() |
Tabs in particular use bs_tabset() + bs_tab_panel() (an id is required and panels are validated) — not tabsetPanel() / tabPanel(). For the other widgets the id is optional: without one they are static markup, which is what a decorative alert or a nav of plain links should be.
Tooltips & popovers decorate an existing tag
bs_tooltip(tag, "text") and bs_popover(tag, "content") wrap a tag you already have (pipe-friendly) and are initialised client-side by bootstrict (Bootstrap does not auto-initialise them). They are UI-only — there is no server-side update / toggle for them.
Interactivity
Interactive components report state and are controllable from the server:
ui <- bs_page(
bs_accordion("acc",
bs_accordion_panel("One", "...", value = "one"),
bs_accordion_panel("Two", "...", value = "two")),
bs_button("open_two", "Open panel two")
)
server <- function(input, output, session) {
observe( print(input$acc) ) # open panel value(s)
observeEvent(input$open_two,
update_bs_accordion("acc", open = "two"))
}The same pattern covers tabs (input$id = active tab, update_bs_tabset()), the carousel (active slide, update_bs_carousel()), collapse, list-group selection, modals (show_bs_modal() / hide_bs_modal()), offcanvas, toasts (show_bs_toast(), bs_notify_toast()) and progress bars (update_bs_progress()).
Coverage
Layout — bs_container(), bs_row(), bs_col() (responsive spans, offsets, order, gutters, alignment), bs_hstack() / bs_vstack() stacks.
Content — bs_table() (data frame → Bootstrap table), bs_img(), bs_figure(), bs_blockquote(), bs_display_heading(), bs_lead(), lists.
Forms — text / textarea / number / password / select / checkbox / switch / radio / checkbox-group / range / color / file / date / date-range inputs, the .btn-check segmented controls (bs_radio_button_input(), bs_checkbox_button_input()), plus bs_input_group(), bs_floating_label(), bs_form(), working validation feedback (bs_feedback() / set_bs_validation()), and .form-check-reverse via reverse = TRUE.
Components — accordion, alert, badge, breadcrumb, buttons & button groups, card, carousel, close button, collapse, dropdown (standalone and in a nav or navbar, bs_nav_dropdown()), list group, modal, nav & tabs (including .nav-underline, 5.3), navbar, offcanvas, pagination, placeholder, popover, progress (including .progress-stacked, 5.3), spinner, toast, tooltip, scrollspy, plus helpers (bs_ratio(), bs_visually_hidden(), bs_vr(), bs_icon_link()).
Colour modes (5.3) — set the initial mode with bs_page(color_mode = "dark"), or "auto" to follow the operating system; switch it from the server with set_bs_color_mode("light") and read the mode in force as input$bootstrict_color_mode. A mode the user picks is remembered in the browser. Component-level dark = TRUE / theme = "dark" arguments emit data-bs-theme per the 5.3 idiom.
Utilities — none are wrapped, deliberately. Every constructor takes a trailing class and forwards named ... as attributes, so a mockup’s class="p-4 text-center" transfers verbatim and a utility Bootstrap adds later works the day it ships.
See ?bootstrict and run the demos:
# The catalogue: every widget, one card each.
shiny::runApp(system.file("examples/demo", package = "bootstrict"))
# The showcase: Quake Watch, a seismic monitor for the Fiji region built on
# datasets::quakes — designer theme from a _variables.scss sheet, offcanvas
# filter drawer, modal event records, colour modes, stacked progress.
shiny::runApp(system.file("examples/quakewatch", package = "bootstrict"))