Interaction
The state language every Barua component speaks — hover, pressed, selected, dragging — plus the drag & drop, selection and keyboard patterns built on top of it. Learn the grammar once and every component reads the same way.
State Conventions
Barua uses one small vocabulary everywhere. Native pseudo-classes do
the work when the browser can (:hover,
:active, :disabled,
:focus-visible); a matching is-* class
exists for every state your JavaScript needs to drive itself.
| State | Class / pseudo | Visual treatment |
|---|---|---|
| Hover | :hover | Fill deepens one step; pointer-only, never fires on touch |
| Pressed | :active / .is-pressed | Scales to 0.97 over --b-duration-instant |
| Current / toggled on | .is-active | Accent-soft background, accent text |
| Selected | .is-selected | Accent-soft background; selectables add an inset accent ring |
| Dragging | .is-dragging | Opacity drops to 0.55, cursor becomes grabbing |
| Disabled | :disabled / .is-disabled | Opacity --b-opacity-disabled (0.4), not-allowed cursor |
| Focused (keyboard) | :focus-visible | Accent glow from --b-focus-ring |
The same button, hard-wired into each state. Hover and focus can only be shown live — mouse over the first one, or Tab to it.
outline: none without a
replacement strands keyboard users. If the default ring clashes,
restyle it through --b-focus-ring — never delete it.
Hover State
Hover is an invitation, not information: the surface tint deepens one
step (--b-fill-quaternary → --b-fill-tertiary)
and interactive cards lift 2px. Treat it as enhancement only — touch
screens never fire it, so nothing may exist solely behind a
hover.
Cards
List items
-
›Amara OkaforRe: Q3 launch checklist
-
›Kelvin MbwiloDesign tokens are merged
Table rows
| Campaign | Status | Sent |
|---|---|---|
| August newsletter | Delivered | 12,408 |
| Product update | Sending | 3,112 |
| Onboarding drip | Draft | — |
Pressed State
Buttons compress to scale: 0.97 on :active
— a 100ms (--b-duration-instant) squeeze that makes
every press feel physical, the way Apple's controls do. When state is
driven from JavaScript instead of the pointer — keyboard activation,
pointer capture on a custom control — apply .is-pressed
to get the identical physics.
Touch Targets
Anything pressable needs a 44px hit area on touch — the
--b-touch-target token. Controls at
--b-control-h-lg and up qualify on their own; for
smaller visuals (an .b-btn--xs icon button, a drag
handle) add the .b-touch-target utility, which expands
the invisible hit area to 44 × 44 without changing the
rendered size.
Active & Selected State
.is-active means "you are here" or "this is on" —
navigation items, segmented controls, toggles.
.is-selected means "this is chosen and something will
happen to it" — list rows, chips, cards in a picker. Both tint with
--b-color-accent-soft so the whole system re-themes from
one accent token.
-
Flight itinerary
-
Invoice #2044
-
Weekly summary
For free-form selection surfaces — file grids, photo pickers, canvas
objects — add .b-selectable. Selected items gain the
accent-soft wash plus an inset accent ring.
aria-selected="true" (options, tabs, rows) or
aria-pressed="true" (toggle buttons) so the state is
announced, not just painted.Disabled State
Disabled controls fade to --b-opacity-disabled (0.4) and
swap to a not-allowed cursor. The native
disabled attribute is the default; the
.is-disabled class produces the same look on elements
that can't take the attribute (links, custom widgets).
disabled removes the control from the tab order — quick,
but invisible to keyboard and screen-reader users, who can't discover
why it's off. When the reason matters (a Send button waiting
on a valid form), prefer aria-disabled="true" +
.is-disabled: the control stays focusable and announced
as dimmed, and your handler simply ignores activation. Add a tooltip
explaining the condition.
Dragging State
Mid-drag, the grabbed element takes .is-dragging: opacity
drops to 0.55 and the cursor switches to grabbing —
on .b-task cards it also tilts 2° for a picked-up feel.
Leave a .b-drag-ghost copy (50% opacity) at the origin so
the layout doesn't jump. Anything with draggable="true"
shows a grab cursor at rest.
Drag & Drop
The full pattern composes four pieces: .b-dropzone around
any list (its 2px dashed border is transparent until needed),
.is-dropover when a drag hovers a valid target (dashed
accent border + accent-soft wash), .b-drop-indicator as
the insertion line between items, and .is-dragging on the
grabbed card. The HTML5 dragstart /
dragover / drop wiring — and toggling these
classes — is app-side JavaScript; Barua supplies the states, not the
engine.
Drag handle & reorder
When the whole row is tappable, reserve dragging for an explicit
.b-drag-handle grip — it keeps scroll and drag from
fighting on touch (touch-action: none is built in).
-
Step 1Welcome email
-
Step 2Follow-up nudge
Resizable Handle
Place a .b-resize-handle between two
.b-panel children of a .b-split. The 9px
grab strip renders as a hairline; on hover — or with
.is-active while dragging — it thickens into an accent
bar. The pointer-tracking that actually resizes the panels is
app-side JavaScript.
For zero-JS cases, .b-resizable uses the browser's native
resize: horizontal (drag the bottom-trailing corner);
.b-resizable--vertical does the same for height.
Drag my corner — no JavaScript involved.
Selection & Multi-Selection
Multi-selection layers three pieces: .b-selectable items
(with .is-selected), a .b-selection-rect
marquee drawn while the pointer rubber-bands across the surface, and
a .b-selection-count readout. The rect is normally
position: fixed and sized from pointer coordinates —
it's frozen here with inline positioning so you can see it.
Once a selection exists, surface its operations in a floating Bulk
Actions bar — see Specialized for
.b-bulk-actions. Support the platform grammar too:
⌘-click toggles, ⇧-click extends a range,
⌘A selects all, esc clears.
Keyboard Shortcut
Shortcuts render with the native <kbd> element —
or .b-kbd on a span — styled as a small key cap with a
weighted bottom edge. Show them inline in help text, in menus and in
the command palette so they're learnable, not secret.
| Shortcut | Action | Convention |
|---|---|---|
| ⌘K | Command palette | Global; the fastest route to anything |
| ⌘S | Save | Intercept the browser default |
| ⌘↵ | Submit / send | Composers and forms |
| esc | Close / cancel | Dismisses the top-most overlay first |
| ⌘⌫ | Delete | Destructive — always confirm or offer undo |
Menus surface the same bindings with
.b-menu__shortcut, right-aligned in each row — see
Navigation for the full menu family.
Focus Ring
Every focusable element shares one ring: --b-focus-ring,
a 3.5px accent glow at 30% opacity. It attaches via
:focus-visible, so it appears for keyboard and assistive
tech but not for mouse clicks — press Tab below to walk
it across four different control types. Inputs pair the ring with an
accent border. Custom focusable elements can opt in with the
.b-focus-ring class.
:focus-visible is
not :focus. :focus matches on every
focus — including mouse clicks — which tempted a generation of
developers into outline: none.
:focus-visible lets the browser show the ring only when
it helps (keyboard, switch access), so there's no reason left to
suppress it. Test by tabbing through every screen: focus must always
be visible and never trapped.Contextual Actions
Row-level actions stay hidden until the row is hovered, keeping dense
lists calm. Inside a .b-datagrid this is automatic —
.b-row-actions cells flip from
visibility: hidden to visible on tr:hover.
The list below recreates both moments statically: the first row is at
rest, the second is shown as if hovered.
-
Quarterly-report.pdfEdited 2 hours ago
-
Launch-plan.keyEdited yesterday — hovered, actions revealed
Hover-revealed actions still need non-hover routes: reveal them on
:focus-within for keyboard users, and keep the same
commands in the row's action menu. On touch, the equivalent pattern
is swipe actions — see Mobile for
.b-swipe.
Scroll reveal
SwiftUI has scrollTransition; the web now has a scroll
timeline. .b-reveal ties an entrance animation to the
element's own position in the scrollport, so the browser drives it —
no observer, no scroll listener, nothing on the main thread.
--fade--scale--start, from the leading edge--end, from the trailing edge<article class="b-card b-reveal">…</article>
<article class="b-card b-reveal b-reveal--fade">…</article>
<article class="b-card b-reveal b-reveal--scale">…</article>
The box is its own scrollport, which is why the effect repeats every time you scroll it. On a page the timeline is the page itself, so each element reveals once as it comes up.
The animation finishes while the element is still travelling up the screen rather than when it reaches the middle, because a reader should never be watching something fade in that they are already trying to read.
@supports (animation-timeline: view()), so where the
timeline is unsupported everything is simply visible. An entrance
animation that fails closed hides the page — which is the one
outcome worse than no animation at all. Reduced motion disables it
like everything else.
Rolling numbers
SwiftUI's contentTransition(.numericText()). A figure that
changes should travel to its new value rather than blink to it — on a
dashboard the movement is what tells you something happened at all.
<span class="b-stat__value" data-b-count="12480">0</span>
<span class="b-stat__value" data-b-count="4.2">TZS 0.0M</span>
Anything carrying data-b-count rolls once it scrolls into
view. Call it directly with Barua.count(el, value) when a
figure changes in place. The formatting it finds is kept — thousands
separators, a currency prefix, a unit suffix — and the element is
given tabular figures for the duration so its width does not jitter as
the digits change.
Scroll geometry
SwiftUI's onScrollGeometryChange. A scroll container
carrying data-b-scroll-geometry writes its own position
onto itself — --b-scroll-y, --b-scroll-x
and --b-scroll-progress (0 to 1) — carries
.is-scrolled once it has moved, and dispatches
b:scroll with the numbers. Put it on
<html> for the page. Anything that reads the
properties is then pure CSS: the progress line below is a
.b-scroll-progress whose width is the distance
scrolled, and the title condenses on the browser's own scroll
timeline with .b-scroll-condense.
Inbox
scrolled
.is-scrolled now.--b-scroll-progress is 1 at the end.
For code, Barua.scroll.geometry(el, cb) calls back with
{ x, y, width, height, contentWidth, contentHeight, progressX, progressY },
coalesced to one read per frame, and returns a function that stops.
Scroll visibility
SwiftUI's onScrollVisibilityChange(threshold:). An
element carrying data-b-visible — optionally the
fraction of itself that counts, data-b-visible="0.8" —
gains .is-visible as it crosses that much into the
viewport, loses it on the way out, and dispatches
b:visible with { visible, ratio }. It is
also the entrance for browsers without a view timeline:
.b-reveal with data-b-visible reveals on
the class where it cannot on the timeline.
For code, Barua.visible(el, cb, { threshold }); one
observer is shared per threshold, so a page of a thousand rows costs
one.
Scroll position
SwiftUI's scrollPosition(id:). A snapping container with
data-b-snap names the child in view:
data-b-snap-current on itself, .is-current
on the child, .is-active on the matching dot in a
data-b-snap-dots="#id" strip, and
b:snapchange with { id, index, element }.
The browser's own scrollsnapchange event drives it where
that exists; elsewhere the nearest child after the scroll settles.
Barua.snap.to(box, idOrIndex) goes there.
in view:
Haptics
SwiftUI's sensoryFeedback. data-b-haptic on
anything clickable — selection, impact,
success, warning, error — plays
a vibration on click; Barua.haptic(kind) does the same
from code and returns whether anything could be felt. The web is
honest about its limits here: Android phones vibrate; an iPhone has
no vibration API, but Safari plays the system haptic when a switch
toggles, so one is toggled off screen; a desktop feels nothing and
says nothing.
Persistence
SwiftUI's customizationID. data-b-persist="key"
keeps a piece of interface state across reloads with no code: a
<details> stays open or shut, a segmented control
or tab strip keeps its choice, a scroll area keeps its place, a field
keeps its value. Change the two below, reload the page, and they are
as you left them. For anything else, Barua.persist.get(key),
set(key, value) and clear(key) hold JSON under
the same keys — which the React kit's usePersistedState
shares, so the two can hand over.