Skip to main content
Web components that make your app’s page look like the Tassyir dashboard it runs in: ink on paper, hairline borders, one cyan highlighter, Arabic right to left and English left to right, light and dark. Assemble your page from these elements instead of inventing styles, and it reads as part of the dashboard. They are custom elements, so they work in any framework (React, Vue, Svelte, Hono JSX) or none. Browser only, ESM only, no runtime dependency. The package collects no analytics, uses no browser storage and makes no network request. About 13.5 kB gzipped for every element.

Install

Example

Your manifest’s admin.page, as plain HTML and TypeScript:
The fields submit with the <form> around them like native ones: new FormData(form) holds sender, delay and skipReturns.

The dashboard’s rules

The elements keep DESIGN.md, the dashboard’s design law. Your page keeps the rest:
  • One ink button per view. variant="primary" is the dashboard’s dark call to action; put the view’s one primary in the page header’s actions. Everything else is outline (the default), ghost or destructive.
  • Status is a <tsy-status-pill>, never a coloured word or a badge of your own.
  • No new colour. Use the tokens (var(--tsy-color-…)), never a hex code. Cyan (--tsy-color-brand-cyan) is a highlighter: selection, progress, charts; never a button or a status.
  • 14px is the floor for any text, and type is the dashboard’s sans.
  • Logical CSS only (margin-inline-start, padding-inline, text-align: start), so the same rules serve Arabic and English.
  • Money and dates read as the dashboard writes them: 9.000 دج in Arabic, DZD 9,000 in English; dates yyyy-MM-dd (yyyy-MM-dd HH:mm).
  • Modals, toasts, resource pickers, the save bar and the page title are not elements here. They are App Bridge calls (modal.open, toast.show, resource.pick, saveBar.show, titleBar.set): the dashboard draws them, signed with your app’s name. See @tassyir/app-bridge.

Language, direction and theme

Your page runs in the dashboard’s frame and cannot see its settings: App Bridge sends them (context.changed: locale, dir, theme). followDashboardContext(bridge) applies the current context at once and every change after it, and returns the function that stops. It sets three attributes on <html>:
  • lang: ar or en. The elements’ own words follow it (“Back”, “Loading”, “Dismiss”, “(opens in a new tab)”: Arabic for ar, English otherwise).
  • dir: rtl or ltr. Every element is drawn with logical properties; arrows and carets mirror; arrow keys in a radio group follow the reading direction.
  • data-theme: light or dark. The tokens switch, with color-scheme.
applyDashboardContext({ locale, dir, theme }, root?) sets them yourself, for example in a page opened outside the dashboard. Light is the default. A subtree with its own data-theme keeps that theme inside a page of the other one. The helpers take @tassyir/app-bridge’s bridge as it is but do not import it: any object with context.current and on('context.changed', …) works.

Elements

Importing @tassyir/ui defines every element (in a browser; on a server it only exports, so server rendering can import it). An attribute that takes a value ignores one it does not know and uses its default. Frameworks: React 19, Vue, Svelte and Lit set a prop as a property when the element has one and as an attribute otherwise; every property here that shares an attribute’s name (value, checked, form, prefix, type, …) takes the attribute’s value, so the markup above works as written. Events are DOM events (input, change, tsy-dismiss): where a framework does not bind them, add a listener with addEventListener (in React, through a ref).

<tsy-button>

The dashboard’s button: 36px, 12px corners, a 14px medium label.
aria-label, aria-pressed and aria-expanded are passed to the inner control. A submit or reset acts after every click listener ran, unless one called preventDefault(). Slot: the label (a 16px <svg> may sit beside it).

<tsy-link>

An underlined link in the colour of the text around it, ink on hover.
aria-label passes to the inner <a>. In the dashboard, your app moving to another of its pages is bridge.navigate({ path }); leaving for a dashboard page is bridge.navigate({ dashboard }).

<tsy-text-field>

A labelled input, or a text area with multiline.
Properties: value, form, validity, validationMessage; methods select(), checkValidity(), reportValidity(), setCustomValidity(). Events: input and change, from the element.

<tsy-select>

A labelled native select: on a phone, the phone’s own picker opens. Write its <option> and <optgroup> elements inside it.
The options are mirrored into the inner select whenever they change. Properties: value, options, selectedIndex, form, validity. Events: input, change.

<tsy-checkbox>

A checkbox with its label as content.
aria-label names a checkbox without content (a table’s row selection). Events: input, change.

<tsy-radio-group> and <tsy-radio>

A labelled set of choices. Arrow keys move and choose (mirrored right to left); the group is one Tab stop.
<tsy-radio-group>: <tsy-radio>: Events (from the group): input, change. Methods: checkValidity(), reportValidity(), setCustomValidity(), focus() (the chosen radio, or the first). A radio added or removed later is taken into account: a removed choice clears the value.

<tsy-card>

The dashboard’s content card. With a heading it is the layered card: a tinted header bar with the title in the quiet label style, an optional count, actions at the end, over a paper body. Without, a plain paper card.
Slots: the body; actions. Never put a card inside a card.

<tsy-page-header>

The page’s title, its one <h1>, with an optional back link, a muted line of facts and the page’s actions.
Slots: meta, actions. The dashboard’s title bar already shows your app’s name; bridge.titleBar.set({ title }) adds the page’s title beside it.

<tsy-table>

The dashboard’s table frame around a native <table> you write inside it: a tinted frame, an optional header bar, a toolbar strip, the rows as a paper panel that scrolls sideways on a phone, and a footer. The table keeps its native semantics.
Slots: the <table>; actions, toolbar, footer. On cells: data-align="end" aligns a number to the end, data-wrap lets a cell wrap. A row with aria-selected="true" is tinted. The table’s styles are adopted into the document (or the shadow root it lives in), in the tassyir-ui cascade layer.

<tsy-status-pill>

The dashboard’s only status treatment: a dot, a 12% tint of the tone, the words in the tone pushed toward ink.
Slot icon: a 16px glyph in place of the dot (a lock, say).

<tsy-banner>

A quiet tinted notice: a glyph, an optional heading, the message and actions.
Slots: the message; actions. Closing fires tsy-dismiss (bubbles, cancelable); unless a listener prevents it, the banner hides. dismiss() does the same from code. A passing confirmation (“Saved”) is a toast: bridge.toast.show.

<tsy-empty-state>

What a list says when it has nothing yet. Keep it for a surface with something to teach; in a form, a plain button says more.
Slots: media (a glyph), the sentence, actions.

<tsy-skeleton>

A loading placeholder in the shape of what is coming. The dashboard shows skeletons, not spinners, while a page or a card loads.
It is hidden from assistive technology; mark the region it stands in aria-busy="true" and clear it when the content arrives. The pulse stops under reduced motion.

<tsy-spinner>

In-place loading: a button, a row.

Forms

<tsy-text-field>, <tsy-select>, <tsy-checkbox>, <tsy-radio-group> and <tsy-button> are form-associated custom elements: they submit with the native <form> around them (FormData, a normal submit), take part in its validation (required, the input’s own constraints, setCustomValidity), are reset with it and follow a disabled <fieldset>. Enter in a one-line field does what it does in a native one: it clicks the form’s first submit button (nothing happens while that button is disabled or loading, so a save in flight is not sent twice); with no submit button, it submits a form that has only that one field.
  • Label every field with its label attribute. A <label for> outside the element does not reach the input inside it.
  • error shows a message and marks the field invalid for assistive technology, but does not block the form: it is for your server’s answer. To block, call setCustomValidity(message).
  • In the dashboard, saving a form the merchant edits is the dashboard’s save bar: bridge.saveBar.show() and its saveBar.save / saveBar.discard events.

Tokens

Importing the package adopts the tokens into the document. Use them in your own CSS:
  • Colours, both themes: --tsy-color-ink, -ink-secondary, -ink-tertiary, -paper, -paper-sunken, -muted-fill, -accent-fill, -hairline, -field, -on-ink, -skeleton, -brand-cyan, -success, -success-soft, -warning, -warning-ink, -warning-soft, -info, -info-soft, -destructive, -destructive-on, -destructive-soft, -purple, -pill-surface, -pill-ink, -control-hover, -ghost-hover, -destructive-fill; shadows --tsy-shadow-paper, --tsy-shadow-control.
  • Type: --tsy-font-sans, --tsy-font-mono; sizes --tsy-font-size-body, -label (14px), -title (18px), -headline (20px), -page-title (24px), -page-title-rtl (22px); weights --tsy-font-weight-regular (400), -medium (500), -semibold (600), -bold (700, the page title only); --tsy-line-height-*.
  • Space: --tsy-space-hint (6px), -hug (8px), -cluster (12px), -grid (16px), -section (24px). Radii: --tsy-radius-control (12px), -nested (8px), -pill.
The same values are data in @tassyir/ui/tokens (no DOM, safe on a server: colors.dark.ink, breakpoints.sm, …) and a stylesheet in @tassyir/ui/tokens.css for a page that wants the tokens without the elements. They sit in the tassyir-ui cascade layer, with the page’s defaults (paper background, ink text, the Tassyir type, 14px body) at zero specificity, so any rule of yours that is not in a layer wins. If your CSS uses cascade layers (Tailwind v4 does), name ours first, at the very top of your stylesheet: layers declared first lose to the ones after, and the package adds its sheet after yours.

Fonts

The dashboard sets Geist (Latin) and IBM Plex Sans Arabic. This package loads no font, so nothing leaves your page; the type falls back to the system’s sans. To show the exact faces, serve them yourself (both are under the SIL Open Font License) under those family names:

Styling

Every element draws in its own shadow root, so your CSS cannot break it by accident, and the tokens reach inside. To adjust one, style the element itself (tsy-card { margin-block: 24px }) or a part: tsy-text-field::part(box). Parts: base, control, label, hint, error, box, prefix, suffix, row, heading, count, actions, body, header, meta, back, toolbar, footer, scroll, dot, media, dismiss, bar, link, options. To hide elements until they are defined, add :not(:defined) { visibility: hidden }.

Accessibility

Built in: a label on every field, its hint and error read with it, a visible focus ring on every control, the arrow-key radio group, a 32px close button, live regions for banners and spinners, text pairs measured at 4.5:1 or more in both themes (a spec measures every pair the elements paint), and motion stopped or slowed under prefers-reduced-motion. Your page’s part: the one <h1> is <tsy-page-header>’s; give icon-only buttons an aria-label; mark loading regions aria-busy.

Content-Security-Policy, Trusted Types, server rendering

The elements write no <style> element and no style attribute (styles are constructable stylesheets; colours set through the CSSOM) and never assign HTML (createElement only). A page served with style-src 'self', script-src 'self' and require-trusted-types-for 'script' works; the demo is served that way. Importing the package on a server defines nothing and throws nothing.

Size

Minified and gzipped, as an app’s bundler ships it (npm run size -w @tassyir/ui): Most of it is CSS. The package is one entry today (import '@tassyir/ui' defines every element).

Checking against the dashboard

src/frontend-parity.test.ts compares this package with the dashboard’s own design in the private tassyir/frontend repository: every colour token against the palette blocks of app/[locale]/globals.css, string for string in both themes; DESIGN.md’s front matter (colours, radii, spacing, type, the button, input and card); and the classes of the dashboard’s primitives each element mirrors. CI cannot reach that repository, so it runs locally:
It reads origin/main (or TASSYIR_FRONTEND_REF) with git show; run git -C ../frontend fetch origin first. npm run browser-check -w @tassyir/ui (after npm run build) runs the demo in a headless Chrome under a strict Content-Security-Policy: real form submission and validity, the keyboard, the accessibility tree, and the demo in Arabic and English, light and dark, at 390 and 1440 px, with a screenshot of each (--out <dir>). npm run demo -w @tassyir/ui serves the demo at http://localhost:4178/demo/.

Not here yet

React and JSX typings for the elements, a custom-elements.json manifest for editors, per-element entry points, and more elements (tabs, a stat card, a popover) come in later releases.