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.
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".
# 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.
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"
)- 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.
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.
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().
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")- 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("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.
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.
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.
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()).
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"))MIT © Colin Fay / ThinkR