UI Components (uni-ui)
uni-ui is a set of ready-made components for RxElement apps on Scala.js: buttons, text fields, tabs, foldable side panes, splitters, and overlays (popovers, tooltips, menus, dialogs, selects, and toasts). The same components work in a browser and in an Electron app. A plain CSS file, uni-ui.css, styles them with light and dark themes.
Components keep their behavior and their look apart. Scala code sets ARIA roles, focus, keyboard handling, and data-state attributes. uni-ui.css styles those with --uni-* CSS variables, so an app restyles a component by setting variables, not by changing Scala code.
Setup
Add the artifact to a Scala.js project:
libraryDependencies += "org.wvlet.uni" %% "uni-ui" % "<version>"Load uni-ui.css once. It ships in the jar at wvlet/uni/ui/uni-ui.css. The sbt-uni plugin copies it out of the jar on every compile, so the file always matches the uni-ui version your app links:
// project/plugins.sbt
addSbtPlugin("org.wvlet.uni" % "sbt-uni" % "<version>")
// build.sbt
lazy val app = project
.enablePlugins(ScalaJSPlugin, UniPlugin)
.settings(uniUiCssFile := baseDirectory.value / "web" / "uni-ui.css")With Vite and @scala-js/vite-plugin-scalajs, vite dev and vite build compile the Scala.js project, so the copy stays fresh. Import it from your app's stylesheet:
@import './uni-ui.css';Every rule sits in the uni cascade layer, so unlayered app CSS always overrides it. If your app uses other layers, declare their order once, before any import. With Tailwind CSS v4:
@layer theme, base, uni, components, utilities;
@import 'tailwindcss';
@import './uni-ui.css';Tailwind utilities then win over uni-ui's rules, so Button("Save")(cls += "w-full") works.
Components
Each component is an RxElement that takes the Rx values it reads. Options are withXXX methods. Pass extra attributes as modifiers, and use cls += so the component's own classes stay.
import wvlet.uni.dom.all.*
import wvlet.uni.dom.all.given
import wvlet.uni.ui.*
val draft = Rx.variable("")
val busy = Rx.variable(false)
div(
Textarea(draft).withRows(4).withPlaceholder("Write a message"),
Button("Send").primary.withDisabled(busy).onClick(() => send(draft.get)),
IconButton("Close", closeIcon).onClick(() => close())
)| Component | What it does |
|---|---|
Button(content*) | <button type="button">. Variants are .primary, .ghost, and .danger, plus .small; set the state with .withDisabled(rx) and the action with .onClick(f) |
IconButton(label, icon) | A square ghost button; label is its accessible name |
Input(text) / Textarea(text) | Bound both ways to an RxVar[String]. Use .withPlaceholder, .withLabel, .withInvalid(rx) (sets aria-invalid), and .withDisabled |
TabList(selected, label)(Tab(value, content*)*) | Tabs bound to an RxVar[A]. Arrow keys, Home, and End move the selection |
TabPanel(tabs)(a => element) | Renders the selected tab's content for the TabList tabs, linked to it with aria-controls and aria-labelledby |
FoldablePane(open, width, edge, splitter)(content*) | A side pane that folds to zero width; its content never reflows |
Splitter(size, range, edge, label) | A resize grip. Drag it, or press the arrow keys (Shift for bigger steps); double-click or Enter resets |
Badge(content*), Badge.dot(label) | A count or a status dot; .withTone(BadgeTone.Accent) |
Kbd(key) | A key cap, <kbd> |
StatusBar(content*) | A bar along the bottom; StatusBar.spacer pushes the rest to the end |
A resizable sidebar:
val open = Rx.variable(true)
val width = Rx.variable(272)
val range = SizeRange(min = 208, default = 272, max = 480)
FoldablePane(open, width, Edge.Right, Some(Splitter(width, range, Edge.Right, "Resize sidebar")))(
ChatList()
)Edge names the pane's inner side, the one that faces the main content: Edge.Right for a left sidebar, Edge.Left for a right-hand dock.
Overlays
Popovers, tooltips, menus, dialogs, selects, and toasts render at the end of <body>, so a pane's overflow: hidden never clips them. The ones that open from a trigger take the trigger element and add aria-expanded, aria-controls, and the toggle to it.
import wvlet.uni.ui.base.{Align, Side}
val filterOpen = Rx.variable(false)
val menuOpen = Rx.variable(false)
val confirm = Rx.variable(false)
div(
Popover(filterOpen)(Button("Filter"))(Input(query).withLabel("Filter")).withAlign(Align.End),
Tooltip("Close dock")(IconButton("Close dock", closeIcon)).withSide(Side.Left),
Menu(menuOpen)(IconButton("More", moreIcon))(
MenuItem("Rename").onSelect(() => rename()),
MenuSeparator,
MenuItem("Delete").onSelect(() => confirm := true)
),
AlertDialog(confirm)("Delete this session?")
.withDescription("Its messages can't be restored.")
.withConfirmLabel("Delete")
.danger
.onConfirm(() => delete())
)| Component | What it does |
|---|---|
Popover(open)(trigger)(content*) | A non-modal panel next to trigger. .withSide, .withAlign, .withLabel |
Tooltip(text)(trigger) | A text label that shows on hover (after .withDelay(ms), 500 by default) and on keyboard focus; text can be an Rx[String] |
Menu(open)(trigger)(entries*) | A list of MenuItems and MenuSeparators. Items take .onSelect, .withShortcut, .withDisabled, and .withChecked(rx) |
ContextMenu(target)(entries*) | A menu that opens at the pointer when target is right-clicked |
Dialog(open)(content*) | A modal dialog with a backdrop. .withTitle, .withDescription, .withActions(buttons*), .noDismissOnBackdrop() |
AlertDialog(open)(title) | A confirm-or-cancel dialog: .onConfirm, .onCancel, .withConfirmLabel, .danger |
Select(selected, options)(label) | A button with a list of options, bound to an RxVar[A]; options can be an Rx[Seq[A]] |
Toaster() | Mount one per app, then call .info, .success, or .error on it to show a message |
They all behave the same way:
- Closing. Escape, a press outside, or setting
open := falsecloses an overlay. When overlays are nested, such as a menu inside a dialog, Escape closes only the top one. - Focus. Opening moves focus into the overlay, and closing moves it back to the trigger. A dialog keeps Tab and Shift+Tab inside it and focuses the element with
autofocusfirst. - Keyboard. In menus and selects, the arrow keys, Home, End, and typed letters move between items, and Enter or Space chooses one.
- Placement. An anchored overlay flips to the other side of its trigger when there's no room, and shifts to stay inside the window. Its final side is in
data-side.
wvlet.uni.ui.base holds the pieces they share, for building your own overlay: Anchored.place (placement math), Dismiss (the Escape and outside-press stack), FocusTrap, and FocusReturn.
Themes
uni-ui.css reads data-theme on <html>: light or dark forces a scheme, and no attribute follows the OS setting. ColorScheme sets the attribute from an Rx:
val scheme = Storage.local("app.colorScheme", ColorScheme.System)
ColorScheme.bind(scheme)
scheme := ColorScheme.DarkTo change colors, set the variables after uni-ui.css:
:root {
--uni-accent: #6d28d9;
--uni-accent-2: #8b5cf6;
--uni-radius: 4px;
}| Variable | Used for |
|---|---|
--uni-canvas, --uni-surface, --uni-panel, --uni-raised | Backgrounds, from the window down to raised controls |
--uni-fg, --uni-muted, --uni-faint | Text, from primary to placeholder |
--uni-line, --uni-hover | Borders and hover fills |
--uni-accent, --uni-accent-2, --uni-accent-fg, --uni-accent-soft | The primary button gradient, its text, and focus rings |
--uni-danger, --uni-danger-soft | Danger buttons and invalid fields |
--uni-success, --uni-success-soft | Success toasts |
--uni-overlay-shadow, --uni-backdrop | Overlay shadows and the dialog backdrop |
--uni-tooltip-bg, --uni-tooltip-fg | Tooltip colors |
--uni-z-popover, --uni-z-dialog, --uni-z-toast | Stacking order of popovers and menus, dialogs, and toasts and tooltips |
--uni-radius-sm, --uni-radius, --uni-radius-lg | Corner radii |
--uni-font-sans, --uni-font-mono, --uni-font-size, --uni-font-size-sm | Fonts |
Testing
Components render into a real DOM with uni-dom testing:
class SendButtonTest extends DomTestSupport:
test("send runs once per click"):
var sent = 0
val m = mount(Button("Send").onClick(() => sent += 1))
fireEvent.click(m.get("button"))
sent shouldBe 1examples/uni-harness builds a full workspace shell with these components and Tailwind CSS.