Appearance
Architecture
This section is for those who want a deeper understanding of how Smalt UI behaves: what is part of the public contract, how styles are delivered, how tree-shaking and SSR work.
Foundation: Reka UI
Smalt UI components are built on top of Reka UI (headless primitives for Vue 3): Reka handles behavior, accessibility and focus management, Smalt UI handles styling with design tokens. That is why reka-ui and vue are declared as peer dependencies: they come from your app and are not duplicated in the bundle.
Styles: global BEM + tokens
- Components use global BEM classes (
.s-name,.s-name__el,.s-name--mod), without scoped styles or CSS Modules. - Class names are stable and part of the public contract: you can hook custom styles onto them (see Theming).
- All values go through CSS variables prefixed with
--s-(three-tier tokens). - The theme is set with the
data-themeattribute on<html>; isolation with the.s-rootcontainer (see Isolation).
CSS delivery and tree-shaking
Styles are delivered in three ways:
@smalt-ui/core/styles.css: the global layer with design tokens, the light/dark theme, the.s-rootcontainer and a minimal normalize. Import it once in the entry point. Without it components have no tokens.- Component CSS: injected automatically when a component is imported (via
vite-plugin-lib-inject-css). Import onlySButton, and only its CSS ends up in the bundle. @smalt-ui/core/fonts.css: the@font-facerules for the Inter font and the files themselves (@smalt-ui/core/fonts/*). A separate, optional entry: an app with its own fonts does not import it (see Fonts).
The package is marked sideEffects: ["*.css"] and built with preserveModules, so unused components are tree-shaken: you pay only for what you import. The SUI plugin (registers all components globally) is convenient, but for the smallest bundle import components one by one.
Component composition
Complex components reuse simpler ones as ready-made S-components instead of pulling in the primitives again. For example, all text and composite fields are built on SFormField (label, hint, error message and a11y wiring), and SIcon is reused by dozens of components. So customizing a base component (e.g. .s-field or its tokens) affects everything that uses it.
SSR and portals
- Components are SSR-safe: they do not access
window/documentwithout checks. This is verified automatically: the library tests render every component on the server. - Floating layers use Reka UI portals (teleport to
<body>). In some environments (static rendering of demos) wrap them in<ClientOnly>. - Toast notifications work through a single
ToastProviderat the app root (imperativeuseToastAPI).
Package and types
ESM-only. The package ships as ES modules (
"type": "module"); theexportsfield points to./dist/index.jsand./styles.css. There is no CJS entry point.Types (
.d.ts) are generated from the sources byvue-tsc: full types of props, slots and models, including types from Reka UI and@internationalized/date(the latter only in the types of date components, declared as an optional peer). They resolve with anymoduleResolutionsetting, bothbundlerandnode16/nodenext.Node ≥ 22.12. A modern build environment is required.
Entry points:
@smalt-ui/core(components, composables, providers),@smalt-ui/core/icons(lucide re-export),@smalt-ui/core/resolver(auto-import forunplugin-vue-components, see Auto-import),@smalt-ui/core/labs,@smalt-ui/core/styles.css,@smalt-ui/core/fonts.css(+ font files@smalt-ui/core/fonts/*) and@smalt-ui/core/scss/*, the SCSS sources of tokens and mixins.SCSS subpath.
@smalt-ui/core/scss/settingsexposes SASS maps ($breakpoints,$spacing, palettes),@smalt-ui/core/scss/toolsexposes mixins (respond-to,field-shell,state-layer),@smalt-ui/core/scss/fontsexposes@font-facerules with a configurable$font-path. Use it where a CSS variable will not do: a media query cannot read one, so the app takes its breakpoints from here instead of duplicating the numbers.scss@use '@smalt-ui/core/scss/tools' as tools; .sidebar { @include tools.respond-to(md) { padding: var(--s-space-4); } }
Labs channel
@smalt-ui/core/labs holds experimental components. Their public API may change without a deprecation cycle, so they are not part of the main entry point and the plugin does not register them by default:
ts
import { createSUI } from '@smalt-ui/core'
app.use(createSUI({ labs: true }))A component moves from labs to the main export once its API has settled; the move is a breaking change for imports only, the behavior stays the same.
Browser support
Styles target current browsers: :where(), color-mix(), clip-path, logical properties, cascade layers (@layer). The lower bound is set by color-mix(): Chrome 111, Safari 16.2, Firefox 113.
Cascade layers
Library styles live in the layers smalt.tokens, smalt.base, smalt.components, smalt.utilities (in this priority order). App rules do not declare layers, and by the spec an unlayered rule beats any layer, so overriding needs neither !important nor a doubled selector:
css
/* a single class is enough */
.checkout-button {
border-radius: 0;
box-shadow: none;
}This matters because component CSS is injected into its JS chunk and reaches the document after the app's global files. Without layers it would always win at equal specificity, and the import order would affect the result.
Layers do not replace isolation from host styles: that still relies on the .s-root container and reset-inherited, see Isolation and embedding.