@sylwellsoftware/fray
v1.4.0
Published
Browser-only TypeScript component runtime, JSX controls, and semantic themes.
Maintainers
Readme
Fray
Fray is a browser-only TypeScript component runtime built around Glue emitters. It provides TSX rendering, explicit component lifecycle, accessible controls and data views, scoped services and routing, and dependency-collected structural CSS. Its built-in messages and calendar display names can be localized once per runtime.
Fray 1.x is ESM-only and targets current evergreen browsers. Install it with its Glue peer:
pnpm add @sylwellsoftware/glue @sylwellsoftware/frayDesign and ownership
Fray presents application values without moving them into a second UI-specific state system. Controls write ordinary Glue emitters, components read the downstream values they need, and applications retain ownership of domain policy and asynchronous work.
application
domain policy, composition, services, endpoints, routes, theme selection
│
▼
Fray
TSX, DOM, events, lifecycle, accessibility, structural presentation
│ get / subscribe / set
▼
Glue
mutable values, derived values, live queries, commands, diagnosticsThe boundaries are deliberate:
| Concern | Owner |
| --- | --- |
| Domain state, validation policy, endpoint configuration, service providers, routes, page composition | Application |
| Translation catalogs, locale policy, application text, document lang/dir | Application |
| DOM structure, native events, accessible semantics, component lifetime, visual async states | Fray |
| Fray-authored message defaults and Fray-owned Intl display names | Fray, using optional runtime localization |
| Mutable and computed values, query execution and status, command lifecycle, optional causality | Glue |
| Retrieval, wire serialization, persistence | Application-supplied handlers and adapters |
| Structural selectors and component layout | Fray component CSS |
| Theme treatment, palette, application layout | Separately loaded CSS and application CSS |
Fray prefers native HTML when it expresses the contract. Custom fray-*
hosts are readable light-DOM ownership and styling boundaries; they are not
registered custom elements and do not use Shadow DOM.
Set up TSX
Use Fray's automatic JSX runtime:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@sylwellsoftware/fray"
}
}Load the variable base, one color palette, and one theme. FrayApp is the
normal application shell: it renders a fixed fray-app host, applies the
theme canvas and typography, and has an accessible primary-content landmark by
default. mountFrayApp() collects reachable structural CSS before mounting:
import {Emitter} from '@sylwellsoftware/glue'
import {
Button,
FrayApp,
Panel,
PanelToolbar,
Textbox,
Toolbar,
createFrayRuntime,
mountFrayApp,
} from '@sylwellsoftware/fray'
import '@sylwellsoftware/fray/themes/base.css'
import '@sylwellsoftware/fray/colors/iceblue/colors.css'
import '@sylwellsoftware/fray/themes/minimal/theme.css'
class ProfileApp extends FrayApp {
readonly name = new Emitter('Ada')
protected override renderContent() {
return <Panel header="Profile">
<PanelToolbar><Toolbar label="Profile actions">
<Button label="Save" onClick={() => this.save()} />
</Toolbar></PanelToolbar>
<Textbox label="Name" valueEmitter={this.name} />
</Panel>
}
onDestroy() {
this.name.dispose()
}
private save() {
console.log(this.name.get())
}
static dependencies = [Button, Panel, PanelToolbar, Textbox, Toolbar]
}
const runtime = createFrayRuntime()
mountFrayApp(runtime, ProfileApp, document.querySelector('#app')!, {
sizing: 'viewport',
layout: 'vertical',
})FrayApp may also be instantiated directly with children. Derived apps
override renderContent(). Its sizing is embedded, viewport-width,
viewport-height, or viewport; layout is horizontal or vertical and
arranges application-owned children directly on that bounded host; landmark
is main (the default) or none for an embedded app. static dependencies is transitive and idempotent. It
declares the Fray and application components whose structural CSS the root can
render. FrayApp itself registers and injects those styles whenever it
attaches; mountFrayApp() is the concise normal entry point. Applications that
prefer a complete static asset may import
@sylwellsoftware/fray/styles/structural.css instead of collecting styles.
FrayApp deliberately does not select a palette, theme, appearance mode,
services, router, routes, or domain state. Those remain application policy.
The low-level h() vnode factory remains exported for non-JSX integrations,
but TSX is the documented authoring model for applications and Fray
components.
Components and lifecycle
A class component has explicit phases:
- The constructor stores props and creates local objects, without subscribing or rendering.
initialize()runs once after Fray assigns the runtime. Create subscriptions or resolve declared services here.render()returns TSX, a primitive, an emitter child, a component, or an array of children.afterMount()runs after the first DOM commit;afterUpdate()runs after later commits.onDestroy()releases resources owned by the component.
watch() schedules a component update when an observable changes.
read(emitter) returns its value and tracks it only for the current render.
snapshot(emitter) tracks and returns {value, fetchState, error}.
onCleanup() registers listeners or other cleanup functions that Fray invokes
on destruction.
class Counter extends Component {
readonly count = new Emitter(0)
readonly label = this.count.map((value) => `Count: ${value}`)
render() {
return <Button
label={this.label}
onClick={() => this.count.set(this.count.get() + 1)}
/>
}
onDestroy() {
this.label.dispose()
this.count.dispose()
}
static dependencies = [Button]
}Fray's synchronous keyed reconciler preserves compatible DOM and component
identity, focus, cursor and native input state, and event-listener cardinality.
Use stable key values for reordered siblings. Never reuse one component
instance under two owners.
Custom component hosts
Wrapped components declare a host stem and render this.Host. The runtime maps
the stem to one fixed, standards-valid name by removing internal hyphens and
prefixing fray-:
interface BadgeProps extends ComponentProps {
tone?: 'neutral' | 'positive'
}
class Badge extends Component<BadgeProps> {
render() {
const Host = this.Host
return <Host data-tone={this.props.tone ?? 'neutral'}>
{this.props.children}
</Host>
}
static override hostName = 'badge'
static override css = css`
& { display: inline-flex; }
&[data-tone="positive"] { color: var(--palette-green); }
`
}The & selector resolves against the concrete host during style collection.
Native-root components render their native element directly. Fray-created DOM
has data-fray for diagnostics, but component styling uses the owning host,
native/ARIA state, fixed part elements, and meaningful traits rather than data
attributes as routine CSS hooks.
Reactive templates
Fray exposes four distinct reactive forms. Choose the form that matches the ownership boundary.
Tracked reads
Use read() when control flow or an ordinary value depends on an emitter. Use
snapshot() when loading and error state matter:
interface Item {
id: string
label: string
}
interface ResultsProps extends ComponentProps {
results: ReadableEmitter<readonly Item[] | undefined>
}
class Results extends Component<ResultsProps> {
render() {
const {value, fetchState, error} = this.snapshot(this.props.results)
if (fetchState === FetchState.Error) {
return <p role="alert">{String(error)}</p>
}
return <ul aria-busy={fetchState === FetchState.Loading}>
{(value ?? []).map((item) => <li key={item.id}>{item.label}</li>)}
</ul>
}
}The surrounding component rerenders when a tracked source changes, and Fray reconciles the tracked source set after every render.
Fine-grained emitter children
A readable emitter in child position updates only its owned DOM range:
<output>Current name: {name}</output>An emitter passed as a normal component prop remains the same object. Fray does not inspect arbitrary prop values or discover dependencies implicitly.
One-way live properties
live() updates a DOM property or a component-declared live prop without
rerendering its parent:
<Button label="Submit" disabled={live(submitting)} />
<output title={live(summary)}>{summary}</output>Built-in components allowlist their live props. TypeScript and runtime checks
reject a binding on an undeclared prop. Value/data emitters such as
valueEmitter, items, and nodes are raw contracts and do not use live().
Two-way native bindings
bind:value accepts a writable string emitter and bind:checked accepts a
writable boolean emitter:
<input aria-label="Search" bind:value={search} />
<input type="checkbox" bind:checked={showArchived} />Fray keeps the property synchronized in both directions and owns the renderer
subscription. Higher-level value controls use the same explicit
valueEmitter convention.
Value-control convention
Stateful controls expose a public writable valueEmitter. Callers can supply
one with valueEmitter, supply an initial uncontrolled value with
defaultValue, or let the control create its documented fallback. value is
retained as an initial-value compatibility alias; it is not a continuously
controlled prop. onChange reports user-driven changes.
Availability and validation can be ordinary values or supported live()
bindings. Labels should be visible whenever possible; ariaLabel is the
fallback for controls without visible label content.
Component reference
Every public component is listed below. Generic className, class, island,
key, and children come from ComponentProps and are omitted from the key
props column.
The generic controls Dropdown, RadioGroup, and Toggle preserve their
option value type through valueEmitter and onChange; the <T> notation in
the tables below denotes that TypeScript type parameter.
Actions, inputs, and choices
| Component | Purpose | Key props and state |
| --- | --- | --- |
| Button | Native button with optional pressed and busy state | label, type, disabled, pressed, busy, busyLabel, error, onClick; live: disabled, pressed, busy, error |
| Toolbar | Named action group | label, orientation |
| Label | Native label for rich or live text | text, htmlFor; live: text |
| Textbox | Labelled native text input with validation | label, valueEmitter, defaultValue, type, name, placeholder, disabled, required, readOnly, busy, error, native text constraints, inputRef, onInput, onChange; live: availability, busy, and error |
| Dropdown<T> | Labelled native select | options, label, valueEmitter, defaultValue, placeholder, disabled, required, busy, error, onChange; options may be static or a readable emitter whose fetch state supplies loading/error feedback |
| RadioButton | Standalone native radio and label | label, name, value, checked, disabled, required, busy, error, onChange; live: state, availability, busy, error |
| RadioGroup<T> | Named native-radio fieldset owning one value | options as [value, label] tuples, label, valueEmitter, defaultValue, disabled, required, busy, error, onChange; options are ordinary render data |
| Toggle<T> | ARIA radio group rendered as toggle buttons | options as [value, label] tuples, label, valueEmitter, defaultValue, disabled, required, busy, error, onChange |
| Checkbox<T> | Configurable keyboard-operable semantic state cycle | symbols as [content, value] tuples, label/ariaLabel, valueEmitter, defaultValue, disabled, required, busy, error, onChange |
| TriCheckbox | Neutral/prefer/deny FilterMode cycle | Same public props as Checkbox, except fixed symbols |
| QuadCheckbox | Neutral/prefer/require/deny FilterMode cycle | Same public props as Checkbox, except fixed symbols |
| DatePicker (experimental) | Text date input with calendar dialog | value props, label/ariaLabel, disabled, required, readOnly, busy, error, date bounds/placeholders, input/change callbacks |
| TimePicker (experimental) | Stepped native time select | value props, label/ariaLabel, disabled, required, busy, error, time bounds/step/placeholders, input/change callbacks |
| DateTimePicker (experimental) | Combined date/time fieldset | combined value props, label/ariaLabel, disabled, required, busy, error, date/time bounds and callbacks |
FilterMode exports neutral, prefer, require, and deny semantic values.
Arrow keys move backward or forward through a multi-state checkbox; Space uses
the native forward cycle.
busy is presentational state: it sets native/ARIA busy semantics and paints
the theme's moving working texture without disabling an input or choice.
Button remains the exception: a busy action is unavailable until it settles.
When error is also present, error presentation wins over the animation.
Every error-bearing control describes its native surface with a focusable
role="alert" overlay. Its icon and initially hidden message are absolutely
positioned so errors do not change layout; the message opens when the icon is
hovered or the alert receives keyboard/tap focus. Applications still own
validation and the message text.
const view = new Emitter<'list' | 'grid'>('list')
<RadioGroup
label="View"
options={[
['list', 'List'],
['grid', 'Grid'],
]}
valueEmitter={view}
/>Layout and navigation
| Component | Purpose | Key props and state |
| --- | --- | --- |
| FrayApp | Fixed fray-app application shell and theme-text boundary | sizing: embedded/viewport axes; layout: horizontal/vertical; landmark: main/none; content or overridden renderContent() |
| Header | Styled native heading surface | level (1–6), headingId, content |
| GroupBox | Labelled bordered group with a vertical header | required header, content |
| OptionGroup | Labelled native fieldset for related controls | label/ariaLabel, OptionGroupHeaderEnd and ordinary content children, disabled, required, busy, error; state props are live |
| OptionsBox | GroupBox specialization arranging option groups | required header, OptionGroup content |
| Layout | Presentation-only arrangement of arbitrary children | exactly one of horizontal/vertical; allocation, scroll, optional accessible-region configuration |
| Panel | Optional labelled, themed region composed over a Layout body | header, horizontal/vertical, allocation, scroll, disabled; PanelToolbar and ordinary content children; live: disabled |
| Sidebar | Labelled aside with fixed header/toolbar and scrolling content | header, ariaLabel; SidebarToolbar and ordinary content children |
| SplitView | Resizable two-pane layout | required SplitPrimary and SplitSecondary Layout panes; horizontal/vertical, allocation, initial/minimum sizes, separator label, onResize |
| NavigationBar | Labelled native navigation list over router-aware or external anchors | required label, items; route items accept exact; external items use {kind: 'external', href} plus disabled/link options |
| Tab | Declarative tab definition consumed by TabPanel | id, label, disabled, optional literal route, content |
| TabLine | Standalone keyboard-operable tab list | tabs, valueEmitter/activeTabEmitter, initial value, label, onChange |
| TabPanel | Tab list plus owned tabpanel sections | declarative Tab children or tabs definitions; value props, mountPolicy, label, onChange |
TabLine supports Home, End, and orientation-appropriate arrow navigation and
skips disabled tabs. TabPanel can register routed tabs when it is mounted in
a router-backed route scope. Its mountPolicy controls content lifetime while
keeping every semantic tabpanel shell stable:
eager(the compatibility default) mounts and retains every tab's content;lazymounts the selected content and retains each visited tab; andactive-onlymounts only the selected content and destroys it on leave.
During initial restoration of a direct nested URL, a routed panel preselects
the matching pending literal route before its first content render. An
active-only panel therefore does not briefly mount its default branch while
the router progressively discovers the requested child scopes.
Use active-only with recreatable TSX/VNodes. A prebuilt component instance
cannot be mounted again after destruction. Put state that must survive a view
instance in application-owned Glue emitters/services, or choose a retaining
policy. Fray does not call data-loading methods implicitly; a mounted view may
activate its application service/query during initialize().
<TabPanel id="profile" label="Profile sections" mountPolicy="active-only">
<Tab id="summary" label="Summary">Summary content</Tab>
<Tab id="details" label="Details">Details content</Tab>
</TabPanel>SplitView is a resizable two-pane composition primitive. Its required named
Layout panes keep their roles and independent arrangement visible:
<SplitView horizontal allocation="flexible" primarySize="18rem"
separatorLabel="Resize project navigation">
<SplitPrimary vertical scroll label="Projects">
<ProjectNavigation />
</SplitPrimary>
<SplitSecondary vertical scroll label="Details">
<ProjectDetails />
</SplitSecondary>
</SplitView>Pointer dragging and orientation-appropriate arrow keys resize the primary
pane. Home and End move to the configured minimum and maximum; Shift multiplies
the keyboard step. SplitView reports pixel sizes through onResize, while the
application owns persistence and responsive policy. Set resizable={false}
only when a fixed divider is deliberate.
NavigationBar uses a native nav, list, and anchors. It preserves
RouteLink href generation, current-route state, modified clicks, targets,
and downloads. An item whose to is {kind: 'external', href} renders a
plain anchor for destinations outside the current router or origin: the
router never intercepts it, no aria-current applies, and no router is
required in the runtime. It has ordinary link tab order and no tab or
ARIA-menu keyboard
model. A disabled item is rendered as a visible non-link with
aria-disabled="true". The bar navigates only; it never locates or owns the
content affected by a route. href is application-controlled and passed to
the native anchor: destination trust, allowed URL schemes, availability, and
cross-application policy remain application responsibilities.
Its --navigation-bar-* and --navigation-link-* theme variables are
independent from --button-*. The base theme deliberately presents navigation
as text links with a subtle hover surface and current-route underline. Themes
may opt into boxed or button-like navigation without changing the component's
native link semantics.
Data and record views
| Component | Purpose | Key props and state |
| --- | --- | --- |
| DescriptionList | Native dl record summary | label, DescriptionItem children |
| DescriptionItem | Native dt/dd pair | required term, value or content |
| InfoPanel | Bordered info panel with optional title and key-value fields | title, label, InfoField children |
| InfoField | Native dt/dd key-value pair | required label, value or content |
| Placeholder | Decorative loading placeholder | numeric width, clamped to 10–100 percent |
| ListView<T> | Keyed single- or multi-select ARIA listbox | items, itemKey, label, placeholderCount, renderItem, multiSelect, selected emitter |
| TreeItem<T> | Declarative tree-node marker | id, label, textValue, value, nested TreeItem children |
| TreeView<T> | Keyed single-select ARIA tree | nodes or declarative items, label, placeholderCount, selected/expanded emitters, renderItem, per-label class/style callbacks, onSelect |
| FilterPanel | Semantic filter-control fieldset | options, filters, filterModes, defaultSemanticState, label, onChange |
| TableHeaderCell | Sort/filter header-cell control | column key/label plus sort/filter state callbacks |
| TableHeader | Header row over public column definitions | columns, sort/filter emitters and callbacks |
| DataTable<T> | Accessible local, caller-query, or REST-backed table | columns, one data input, rowKey, caption/messages, placeholderCount, semantic filter options, single/multi selection |
ListView, TreeView, and DataTable reconcile selection by stable keys when
fresh item objects arrive. Supply an explicit key for application data; index
fallbacks are only safe for immutable ordering. ListView.items and
TreeView.nodes accept static arrays or readable emitters and present loading,
empty, and error states from the emitter snapshot.
On an empty initial/loading snapshot, all three collection views render
deterministic, aria-hidden placeholder rows; placeholderCount selects their
count. When a loading snapshot retains rows, those real rows remain semantic
and usable while the working texture animates over their background. Error
snapshots retain any available rows, add an error edge and overlay detail icon,
and stop the loading animation. A DataTable data source with retry also
renders its localized retry action.
Advanced compositions may use BaseSelectionHandler,
SingleSelectionHandler, MultiSelectionHandler, and
createSelectionHandler directly. Ordinary applications should prefer the
selection behavior already owned by ListView and DataTable.
TreeView owns keyboard navigation, expansion, typeahead, and selection. Use
itemLabelClassName and itemLabelStyle when only the label beside the
expander needs a reusable presentation trait such as colored.
DataTable inputs and ownership
DataTable requires exactly one data mode:
data: a static array or readable emitter; the table owns the local derived data source it creates.dataSource: a caller-ownedTableDataSource; the caller disposes it.rest: convenience options for a table-owned REST-backed source.
For reusable sources, use createLocalTableDataSource,
createQueryTableDataSource, createHandlerTableDataSource, or
createRestTableDataSource. Sources expose query, sortEmitter,
filtersEmitter, optional retry, and dispose().
TableColumn definitions own display and local comparison/filter functions.
When a column's visible label is rich content, supply its textual
ariaLabel for Fray-generated sort and filter control names.
The pure applyLocalTableState, serializeTableQuery, and related table-query
helpers keep local behavior and remote encoding explicit. Pagination,
virtualization, and server-specific wire policy remain application concerns.
Dialog, status, and presentation selection
| Component | Purpose | Key props and state |
| --- | --- | --- |
| Dialog | Controlled native modal with focus containment and restoration | title, description, DialogActions and ordinary content children, valueEmitter/defaultValue, closeLabel, showCloseButton, initialFocusRef, onClose |
| ProgressBar | Labelled native progress with visual track | required label, value or valueEmitter, max, valueText; null is indeterminate |
| ThemePicker | Select and replace a Fray theme link | value props, options, label/ariaLabel, disabled, targetDocument, onChange |
| ColorPicker | Select and replace a Fray color link | same contract as ThemePicker |
The pickers use frayThemeOptions and frayColorOptions by default. An
application still owns whether runtime selection is offered, which options are
available, and whether the selected identifier is persisted.
Semantic filter state
Fray's filter helpers keep presentation symbols separate from matching policy.
A FilterState is plain, versionable data keyed by dimension and option. A
FilterDimensionDefinition supplies the application-owned matchers.
Dimensions combine with AND. Within a dimension, deny wins, every required option must match, and at least one preferred option must match when any are active. Unknown persisted keys survive serialization without constraining current matching.
Use matchesFilterState or filterByState for pure evaluation;
deriveFilterPredicate and deriveFilteredItems for reactive results; and
serializeFilterState/parseFilterState for deterministic versioned data.
Localization
Fray can consume the result of your existing localization system for text and formatting that Fray itself owns. Configure it once when creating the runtime:
import {createFrayRuntime} from '@sylwellsoftware/fray'
import type {FrayMessageOverrides} from '@sylwellsoftware/fray'
const messages: FrayMessageOverrides = {
toolbarLabel: i18n.t('fray.toolbar.label'),
dialogCloseLabel: i18n.t('fray.dialog.close'),
dataTableEmpty: i18n.t('fray.table.empty'),
tableSortColumnLabel: (label) => i18n.t('fray.table.sort', {label}),
checkboxStateLabel: (label, state) =>
i18n.t('fray.checkbox.state', {label, state}),
}
const runtime = createFrayRuntime({
localization: {
locale: i18n.locale,
messages,
},
})locale is a non-empty BCP 47 tag. Fray canonicalizes it and uses it for the
calendar's complete month/year heading, weekday names, and day numerals. The
calendar stays Gregorian and currently remains Sunday-first. Calendar display
values come from Intl; do not add them to the message object.
Every FrayMessageOverrides property is optional. Fray copies supplied values
at runtime construction and fills omitted properties from English defaults.
Fixed messages are strings; messages that insert a label are typed functions,
so the organization's localization adapter controls word order and
interpolation. Explicit component props such as Dialog.closeLabel,
Toolbar.label, or DataTable.emptyMessage still take precedence.
The configuration is immutable and runtime-local. It is not a ServiceScope
service or a live locale binding. To select another language, create and mount
a runtime with the new localization configuration. Multiple runtimes may use
different locales on one page.
Fray does not load catalogs, select or persist a locale, define fallbacks or
plural rules, translate caller-provided labels/errors/content, or set the
document's lang or dir. The application must set lang consistently with
the configured locale and owns RTL behavior. Localized parsing, locale-specific
week starts, time/number/percentage formatting, and collation are not part of
this contract.
Checkbox.ariaLabel and TableColumn.ariaLabel are textual alternatives for
rich visible labels used inside Fray-generated accessibility messages. For
ordinary string/number labels they are unnecessary.
Application services
Service implementations remain ordinary application TypeScript. Fray provides typed keys and a fixed application scope, not dependency discovery:
class ProjectService {
readonly label = 'Projects'
}
const projectService = defineService<ProjectService>('projects')
const services = createServiceScope([
provideService(projectService, () => new ProjectService()),
])
class ProjectTitle extends Component {
static requiredServices = [projectService]
private service!: ProjectService
initialize() {
this.service = this.requireService(projectService)
}
render() {
return <output>{this.service.label}</output>
}
}
const runtime = createFrayRuntime({services})Providers are immutable, lazy, and scope-shared. Factories can explicitly
resolve declared dependencies through their ServiceResolver; cycles and
missing providers fail clearly. ServiceScope.dispose() disposes initialized
services in reverse creation order. Components own the queries/results they
open; they do not dispose scope-shared services.
FrayRuntime carries one ServiceScope, optional router, optional static
localization, and isolated StyleRegistry. createFrayRuntime() is the normal
construction entry point; defaultFrayRuntime supports direct compatibility
mounting with English messages and the browser's default locale.
Browser routing
Fray routing binds explicit route vocabulary to ordinary writable emitters. The application owns descriptors, codecs, data-dependent resolvers, and the navigation adapter.
const portfolioRoute = defineRoute('portfolio')
const registerRoute = defineRoute('register')
const projectRoute = defineRouteParameter('project', stringRouteCodec)
const selectedProject = new Emitter<string | null>(null)
const activeApplication = new Emitter<Key | null>('portfolio')
const router = createBrowserRouter({adapter: createHashNavigation()})
const runtime = createFrayRuntime({router})
<NavigationBar
label="Application sections"
items={[
{id: 'portfolio', label: 'Portfolio', to: routeTarget(portfolioRoute)},
{id: 'register', label: 'Register', to: routeTarget(registerRoute)},
]}
/>
<RouteOutlet
valueEmitter={activeApplication}
mountPolicy="active-only"
views={[{
id: 'portfolio',
route: portfolioRoute,
content:
<RouteValue
route={projectRoute}
valueEmitter={selectedProject}
scopeChildren={true}
>
<ProjectScreen selectedProject={selectedProject} />
</RouteValue>,
}, {
id: 'register',
route: registerRoute,
content: <RegisterScreen />,
}]}
/>Core routing exports:
defineRoute,defineRouteParameter,routeParameter,routeTarget, andwithRouteQuerycreate immutable descriptors and targets.BrowserRouter/createBrowserRouterprogressively restore mounted scopes, normalize locations, and expose structured issue state.createHistoryNavigation,createHashNavigation, andMemoryNavigationAdapterdecide where locations live.RouteScopeestablishes lineage;RouteValuebinds dynamic path values;RouteQuerybinds one named query value;RouteLinkrenders a real anchor.NavigationBargroups native route links and external-destination anchors but does not own destination DOM.RouteOutletregisters one sibling literal-route set against an application-owned emitter and gives selected content its resolved scope.waitForRouteValuelets a resolver await a readable application prerequisite with cancellation.
Resolvers may return RouteRedirect through redirectTo(), or throw
RouteUnavailableError when the requested value cannot be represented in the
mounted application state.
Explicit navigation pushes by default. Restoration never pushes; redirects, fallback, canonicalization, and passive bound-state changes replace. A superseding transition aborts pending resolvers. Invalid locations settle at the deepest valid parent and leave accessible issue presentation to the application.
The history adapter needs server fallback for direct deep requests. The hash adapter reserves the fragment. The memory adapter is intended for deterministic tests. The caller owns and disposes the router.
RouteOutlet.mountPolicy uses the same ContentMountPolicy values as
TabPanel: eager, lazy, and active-only. Immediate routes are registered
whether or not their content is mounted. During direct restoration, the
matching pending branch is selected before the first content render, so an
active-only default branch cannot initialize and activate unrequested work.
Other page regions may independently read activeApplication; only the outlet
registers that sibling route set. Application-global navigation should usually
use explicit routeTarget(...) values, while relative descriptors are suited
to a navigation bar inside the route scope that registered them.
Styling contract
Load presentation in this order:
@sylwellsoftware/fray/themes/base.css- Collected CSS or
@sylwellsoftware/fray/styles/structural.css - One
@sylwellsoftware/fray/colors/<name>/colors.css - One
@sylwellsoftware/fray/themes/<name>/theme.css
base.css declares variables and derives palette roles but contains no
component selectors. Color files provide anchors and endpoints. Component
static css owns selectors, layout, pseudo-elements, native states, and
interaction mechanics.
Theme files provide intentional overrides. Custom properties are the primary
instrument and belong on :root inside @layer theme, with color-scheme as
the only ordinary property in that block. A theme may also write ordinary CSS
rules when no variable expresses the intended difference, but those rules must
be placed after the @layer theme block: component CSS is injected as an
unlayered <style> element prepended to <head>, so unlayered theme rules win
by document order while layered ones would always lose.
frayThemeVariableCatalog describes the supported palette and semantic
variable hierarchy. findFrayStylesheetOption, replaceFrayStylesheet,
setFrayAppearance, and getFrayAppearance support application-controlled
runtime selection.
Root sizing and typography
FrayApp is block-level and always applies --application-background,
--ui-color, --font-family, --font-size, and --line-height, so all of
its native and Fray descendants inherit theme text treatment even when it is
embedded. Its sizing prop maps to fray-fill-horizontal,
fray-fill-vertical, or both to claim 100vw, 100vh, or the full viewport.
Its optional layout prop maps to the direction trait on that same host. This
is important for viewport shells: Flexbox only distributes an already bounded
size, so an auto-height intermediate wrapper does not inherit the root's
height constraint automatically.
The document itself remains application policy. A viewport-sized root claims
100vh, so the browser's default body margin would overflow it into
document scrollbars; dedicated app documents should remove it:
html, body { margin: 0; }The traits remain available for applications that use a plain Component
root. They now apply the same canvas, text color, and typography values. A
plain root without either trait remains content-sized and inherits host-page
text treatment.
Native elements, traits, and component hosts
Fray does not assign component or surface presentation to application-owned
native elements merely because of their element type. Native elements such as
header, footer, main, section, and aside retain their ordinary HTML
semantics.
Applications may explicitly opt native elements into Fray presentation and
layout contracts by applying public Fray traits such as island,
fray-layout-horizontal, fray-layout-vertical, fray-size-natural,
fray-size-flexible, and fray-scroll. These traits are intentionally
element-agnostic and may style application-owned native markup as well as
Fray-owned hosts.
A Fray component host is therefore not required merely to obtain Fray layout or
surface treatment. Use Layout when no native semantic element is appropriate
and the container exists only to arrange children. Introduce any other component
when it owns a meaningful structural, behavioral, accessible, or presentation
contract.
In short: native element names provide semantics, Fray traits provide opt-in presentation and layout, and Fray component hosts provide component-owned contracts.
Reusable traits
The allocation traits are structural and independently composable:
fray-layout-horizontalandfray-layout-verticalarrange direct children and stretch them across the other axis;fray-size-naturalkeeps a direct child's application/content allocation;fray-size-flexibleshares remaining main-axis space and supplies zero logical minimums so nested content can shrink;fray-scrollmakes a bounded node the explicit overflow owner.
Header, Layout, NavigationBar, Panel, Sidebar, SplitView, and
Toolbar accept
allocation="natural" | "flexible" and map it to their outer host. Panel
uses horizontal or vertical for its inner Layout body rather than its
generated header and toolbar. SplitView applies direction to the relationship
between its two panes; each pane independently arranges its own children.
Application-owned elements may use the classes directly.
Layout, Panel, and SplitView reject simultaneous horizontal and
vertical modifiers. Layout requires one explicitly. Panel and SplitView retain
their former vertical and horizontal defaults, respectively, while the legacy
orientation and direction spellings remain compatibility aliases.
Flexible siblings have equal growth shares only when their box decoration is
equivalent. Application CSS may override ratios, sizes, and gaps. Flexible
allocation does not imply scrolling, and an island does not select the scroll
owner; filled-root island overflow remains a compatibility fallback. Rules use
no !important, so later application CSS can refine them. Fray does not yet
provide breakpoint variants.
island marks one deliberate themeable surface boundary. Pass
island={true} to a wrapped component or use the class on application-owned
native markup. Fray rejects nested component islands; application markup must
preserve the same one-layer invariant.
colored consumes an explicit --c1, --c2, --c3 triplet for the shared
gradient and --colored-shadow treatment. It does not choose semantic colors
for the application.
Accessibility and browser support
Fray components use native controls and landmarks where possible, expose accessible names, preserve focus during keyed updates, and render loading, empty, and error messages outside collection semantics. The browser matrix covers pinned Chromium, Firefox, and WebKit builds, including keyboard flows, 200% text, forced colors, and automated accessibility checks.
Applications remain responsible for meaningful labels, heading hierarchy, domain validation messages, color contrast introduced by application CSS, focus order across composed screens, and manual assistive-technology testing.
Fray does not support SSR, hydration, Shadow DOM, registered Web Components, legacy browsers, or a concurrent rendering scheduler.
Further reference
- Public API surface
- Architecture
- Application composition guide — design screens, component boundaries, state lifetimes, and layouts.
- Repository layout guide — organize application source by responsibility and feature.
- Theme contract
- Color palette contract
- Release history
