Skip to content

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:

scala
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:

scala
// 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:

css
@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:

css
@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.

scala
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())
)
ComponentWhat 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:

scala
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.

scala
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())
)
ComponentWhat 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 := false closes 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 autofocus first.
  • 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:

scala
val scheme = Storage.local("app.colorScheme", ColorScheme.System)
ColorScheme.bind(scheme)
scheme := ColorScheme.Dark

To change colors, set the variables after uni-ui.css:

css
:root {
  --uni-accent: #6d28d9;
  --uni-accent-2: #8b5cf6;
  --uni-radius: 4px;
}
VariableUsed for
--uni-canvas, --uni-surface, --uni-panel, --uni-raisedBackgrounds, from the window down to raised controls
--uni-fg, --uni-muted, --uni-faintText, from primary to placeholder
--uni-line, --uni-hoverBorders and hover fills
--uni-accent, --uni-accent-2, --uni-accent-fg, --uni-accent-softThe primary button gradient, its text, and focus rings
--uni-danger, --uni-danger-softDanger buttons and invalid fields
--uni-success, --uni-success-softSuccess toasts
--uni-overlay-shadow, --uni-backdropOverlay shadows and the dialog backdrop
--uni-tooltip-bg, --uni-tooltip-fgTooltip colors
--uni-z-popover, --uni-z-dialog, --uni-z-toastStacking order of popovers and menus, dialogs, and toasts and tooltips
--uni-radius-sm, --uni-radius, --uni-radius-lgCorner radii
--uni-font-sans, --uni-font-mono, --uni-font-size, --uni-font-size-smFonts

Testing ​

Components render into a real DOM with uni-dom testing:

scala
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 1

examples/uni-harness builds a full workspace shell with these components and Tailwind CSS.

Released under the Apache 2.0 License.