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:
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")
install.packages("bootstrict", repos = c('https://thinkr-open.r-universe.dev', 'https://cloud.r-project.org'))
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"
)
snake_case, prefixed bs_ (no masking of Shiny).... works exactly like Shiny/htmltools: named args become HTML
attributes, unnamed args become children.id; their value is input$id.shiny::*Input(), so the reactive
value and every updateXxx() keep working identically — bootstrict only
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:
bs_page() / bs_container()), not nested inside a bs_card() or other
positioned element — Bootstrap can otherwise clip or mis-position them.input$id (TRUE when
shown) — Shiny modals don't. You can also open them with no server round
trip using the UI triggers bs_modal_trigger(), bs_offcanvas_trigger()
and bs_collapse_trigger().id first and session lastShiny'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")
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 matching shiny::*Input() and only
restyle the markup, so input$id and Shiny's own updateXxx() keep
working unchanged — use shiny::updateTextInput() etc. for these.bs_range_input(), bs_color_input(),
bs_date_input(), bs_date_range_input(), bs_radio_button_input() and
bs_checkbox_button_input(). Either Shiny has no equivalent, or its
equivalent is not Bootstrap markup. They ship their own bindings, so drive
them with update_bs_range() / update_bs_color() /
update_bs_date_input() / update_bs_toggle_buttons() (Shiny's
updateSliderInput() and updateDateInput() 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's selectInput() adds by
default.bs_range_input() is a native HTML <input type="range">, not Shiny's
sliderInput() (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 in bootstrap-datepicker — a
third-party stylesheet whose calendar is nowhere in the Bootstrap docs. The
cost is that format, language, weekstart and datesdisabled are gone;
drive them with update_bs_date_input().bs_feedback(): Bootstrap only shows a message
that is a sibling of the marked control, and a bare
bs_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 idbs_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.
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.
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