Kit & Core Reference

@uniweb/kit is the developer-facing API for Uniweb foundations. It provides React hooks for accessing site context, primitive components (Link, Image, Icon), and utility functions. When you need something from the framework, you import it from kit.

The data classes underneath — Website, Page, Block from @uniweb/core — are documented below for reference. You access them through hooks and the block prop, not by importing core directly.


Hooks

useWebsite

Access the current website instance.

import { useWebsite } from '@uniweb/kit'

function Header() {
  const { website } = useWebsite()

  return (
    <header>
      <h1>{website.name}</h1>
      <nav>
        {website.getPageHierarchy({ for: 'header' }).map(page => (
          <a key={page.id} href={page.route}>{page.label}</a>
        ))}
      </nav>
    </header>
  )
}

Return Value

const { website } = useWebsite()
PropertyTypeDescription
websiteWebsiteThe active website instance

useRouting

SSG-safe access to routing functionality. Returns hook functions that you call to get routing data.

import { useRouting } from '@uniweb/kit'

function NavLink({ href, children }) {
  const { useLocation, useNavigate } = useRouting()
  const location = useLocation()
  const navigate = useNavigate()

  const isActive = location.pathname === href

  return (
    <a
      href={href}
      className={isActive ? 'active' : ''}
      onClick={(e) => {
        e.preventDefault()
        navigate(href)
      }}
    >
      {children}
    </a>
  )
}

Return Value

PropertyTypeDescription
useLocation()functionReturns location object { pathname, search, hash }
useParams()functionReturns the route parameters of a parametric page
useNavigate()functionReturns navigate function for programmatic navigation
LinkcomponentRouter Link component (or 'a' fallback)
isRoutingAvailable()functionCheck if router context is available

SSG Safety: During SSG/prerender, these return sensible defaults (empty pathname, empty params, no-op navigate) instead of throwing errors.

useActiveRoute

Active route detection for navigation highlighting.

import { useActiveRoute } from '@uniweb/kit'

function NavItem({ page }) {
  const { isActive, isActiveOrAncestor } = useActiveRoute()

  return (
    <a
      href={page.route}
      className={isActiveOrAncestor(page) ? 'active' : ''}
    >
      {page.label}
    </a>
  )
}

Return Value

PropertyTypeDescription
routestringCurrent normalized route (e.g., 'docs/getting-started')
rootSegmentstringFirst segment of route (e.g., 'docs')
isActive(pageOrRoute)functionCheck exact match with current route
isActiveOrAncestor(pageOrRoute)functionCheck if page or its children are active

Both isActive and isActiveOrAncestor accept a page object or a route string:

isActive(page)          // page object with .route property
isActive('/blog')       // route string — same comparison logic
isActiveOrAncestor('/research')  // matches /research, /research/papers, etc.

Use isActiveOrAncestor for parent nav items that should highlight when child pages are active.

Why useActiveRoute instead of website.activePage: The hook reads from React Router's location, which updates synchronously during navigation. website.activePage is a property on a vanilla JS singleton — it's always correct but isn't reactive in the React sense, so it won't trigger re-renders on its own. The hook also delegates route comparison to Website.isRouteActive(), which handles normalization and base path, and accepts both page objects and strings without branching.

usePageState / useWebsiteState

Bridge observable state on page and website into React. page.state is scoped to the current page; website.state is site-wide. Both persist across SPA navigation and are readable from outside React (sibling components, non-React helpers).

import { usePageState } from '@uniweb/kit'

function QuerySelector() {
  const [slug, setSlug] = usePageState('selectedQuery', 'all-members')

  return (
    <select value={slug} onChange={(e) => setSlug(e.target.value)}>
      <option value="all-members">Everyone</option>
      <option value="tenured-biology">Tenured biologists</option>
    </select>
  )
}

Signature:

const [value, setValue] = usePageState(key, defaultValue?)
const [value, setValue] = useWebsiteState(key, defaultValue?)

The hook subscribes to the keyed slot of the active page's (or website's) state, returns the current value (or defaultValue if unset), and re-renders the calling component when the slot changes. The setter writes to the same slot and fires listeners across any other subscribers.

State changes drive React re-renders, not re-fetches. Components subscribed to the changed slot re-render and recompute from already-loaded data (typically via useMemo keyed on the state value, or a utility like @uniweb/core's matchWhere). The framework does not re-dispatch fetches when state changes — the Uniweb model is "fetch once, filter in place" for this pattern. Components that need to re-fetch on user action are domain-aware components that own their own fetching with standard React (useEffect + fetch). See Component Data Patterns for the two-role framing.

What belongs in page.state / website.state:

StateWhere it goes
Filter / sort / toggle state that reshapes already-loaded datapage.state or website.state
Cross-section coordination on a single page (active tab across a tab group, collapsed sidebar state)page.state
Cross-page UI (appearance preference, authenticated user, cart open/closed)website.state
Component-internal UI state (modal open, input focus, hover)React's useState — not these hooks
Fetched datacontent.data — not these hooks

These are small observable value stores — plain keys with values. No reactive derivations, no computed signals; if you want that, compose useMemo / useEffect in the kit layer around these hooks.

useVersion

Access version information for versioned documentation.

import { useVersion } from '@uniweb/kit'

function VersionSwitcher() {
  const {
    isVersioned,
    currentVersion,
    versions,
    getVersionUrl,
    isDeprecatedVersion
  } = useVersion()

  if (!isVersioned) return null

  return (
    <select
      value={currentVersion?.id}
      onChange={(e) => window.location.href = getVersionUrl(e.target.value)}
    >
      {versions.map(v => (
        <option key={v.id} value={v.id}>
          {v.label}
          {v.deprecated && ' (deprecated)'}
        </option>
      ))}
    </select>
  )
}

Return Value

PropertyTypeDescription
isVersionedbooleanIs current page in a versioned scope?
currentVersionobject{ id, label, latest, deprecated }
versionsarrayAll versions in current scope
latestVersionIdstringID of the latest version
versionScopestringRoute where versioning starts
isLatestVersionbooleanIs current the latest version?
isDeprecatedVersionbooleanIs current version deprecated?
getVersionUrl(id)functionCompute URL for a version
hasVersionedContentbooleanDoes site have any versioned content?
versionedScopesobjectMap of scope → { versions, latestId }

useThemeData

Access theme configuration.

import { useThemeData } from '@uniweb/kit'

function ColorPalette() {
  const theme = useThemeData()

  if (!theme) return null

  const palettes = theme.getPaletteNames()
  const primary500 = theme.getColor('primary', 500)

  return (
    <div style={{ color: primary500 }}>
      Available: {palettes.join(', ')}
    </div>
  )
}

See Site Theming for the full Theme API.

useColorContext

The color context a section renders in — 'light', 'medium' or 'dark'. A section pinned with theme: renders in that context; one that follows the site renders in the site's current scheme. For a component's own logic that tokens can't cover — a light or dark logo, say — since theme is a setting of the section, not a param.

import { useColorContext } from '@uniweb/kit'

function Logo({ block }) {
  const context = useColorContext(block)
  return <img src={context === 'dark' ? '/logo-light.svg' : '/logo.svg'} alt="" />
}

useAppearance

Control light/dark mode.

import { useAppearance } from '@uniweb/kit'

function DarkModeToggle() {
  const { scheme, toggle, canToggle } = useAppearance()

  if (!canToggle) return null

  return (
    <button onClick={toggle}>
      {scheme === 'dark' ? '☀️ Light' : '🌙 Dark'}
    </button>
  )
}

Return Value

PropertyTypeDescription
schemestringCurrent scheme: 'light' or 'dark'
togglefunctionSwitch between schemes
canTogglebooleanIs toggling enabled?
setScheme(s)functionSet a specific scheme

The runtime applies the scheme class to <html> before the page paints, and this hook reads it back — so scheme is correct on the very first render, with no flash. toggle and setScheme persist the visitor's choice.

Don't also write the class or touch localStorage from your component. That makes a second writer racing the runtime's, which is how a toggle ends up needing two clicks. canToggle is false unless the site's theme.yml enables toggling — if your button never appears, that's the config to check. See site theming.

useThemeColor / useThemeColorVar

Convenience hooks for accessing theme colors.

import { useThemeColor, useThemeColorVar } from '@uniweb/kit'

function Badge() {
  // Get actual color value
  const accentColor = useThemeColor('accent', 600)

  // Get CSS variable reference
  const primaryVar = useThemeColorVar('primary', 500)

  return (
    <span style={{ background: accentColor, borderColor: primaryVar }}>
      New
    </span>
  )
}

useInView

Detect when an element enters the viewport. Useful for lazy loading and scroll animations.

import { useInView } from '@uniweb/kit'

function LazyImage({ src, alt }) {
  const { ref, inView } = useInView({
    threshold: 0.1,
    triggerOnce: true
  })

  return (
    <div ref={ref}>
      {inView ? (
        <img src={src} alt={alt} />
      ) : (
        <div className="placeholder" />
      )}
    </div>
  )
}

Options

OptionTypeDefaultDescription
thresholdnumber0Visibility threshold (0-1)
triggerOncebooleanfalseOnly trigger once
rootMarginstring'0px'Margin around root

useHeadings

The headings of the page being read, and which one the reader is level with. What a table of contents needs, with no opinion about how it looks.

import { useHeadings } from '@uniweb/kit'

function PageContents() {
  const { headings, activeId, scrollTo } = useHeadings()
  if (!headings.length) return null

  return (
    <nav aria-label="On this page">
      {headings.map((heading) => (
        <button
          key={heading.id}
          onClick={() => scrollTo(heading.id)}
          className={heading.id === activeId ? 'text-primary' : 'text-subtle'}
        >
          {heading.text}
        </button>
      ))}
    </nav>
  )
}

Headings come back nested — each carries { id, text, level, children }. The list is derived from the page's own content, so it is present in prerendered HTML rather than appearing after hydration; only activeId needs the browser, because only scroll position does. A DOM scan is the fallback for content the hook cannot see.

The anchor ids match what <Render> and <Prose> stamp — same generator — so scrollTo always finds its target.

Options

OptionTypeDefaultDescription
levelsnumber[][2, 3]Heading levels to collect
rootstring'main'Selector to scan, for the DOM fallback
offsetnumberheader heightScroll offset in px; defaults to how far the header reaches — --uw-sticky-top when the header is a sticky section, else --header-height — plus a little room

Pair it with website.getBranchHierarchy({ route, for: 'left' }) for the other half of a documentation shell — the navigation rail. See Website.

useShortcut / useShortcuts / useShortcutLabel

Bind a keyboard shortcut, and render it the way the visitor's platform writes it.

import { useShortcut, useShortcutLabel } from '@uniweb/kit'

function Header() {
  const [open, setOpen] = useState(false)
  useShortcut('mod+k', () => setOpen(true))

  return (
    <button onClick={() => setOpen(true)}>
      Search <kbd>{useShortcutLabel('mod+k')}</kbd>
    </button>
  )
}

mod is the portable modifier: it matches Meta or Control on every platform, so a binding cannot silently fail to fire on somebody's machine. Platform detection happens only in the label, where a wrong guess is cosmetic rather than a dead shortcut. useShortcutLabel('mod+k') returns ⌘K on Apple platforms and Ctrl+K elsewhere — which is the point: a hardcoded ⌘ names a key that Windows and Linux visitors do not have.

Kit ships no default binding and knows no action names. Which key opens what is your foundation's decision, the same way the site owns its theme values.

useShortcut('/', focusSearch)                    // bare key
useShortcut('escape', close, { whileTyping: true })
useShortcuts({ 'mod+k': open, escape: close })   // one listener for several

Binding syntax: modifiers mod, ctrl, alt/option, shift, meta/cmd, joined with +; keys are a single character or a name (escape, enter, space, tab, up/down/left/right). Case-insensitive.

Options

OptionTypeDefaultDescription
enabledbooleantrueBind only while true
whileTypingbooleanderivedFire while an input, textarea or contenteditable has focus
preventDefaultbooleantrueCall preventDefault() when it fires
targetEventTargetwindowListen somewhere other than window

whileTyping defaults from the binding rather than a fixed value: a modifier combo fires while a field has focus — including the field it opens — and a bare key does not, since it would otherwise hijack typing. Pass it explicitly to override; escape usually wants true.

SSR-safe: everything touching the DOM runs inside an effect, so these are inert during prerender. In development, binding one combo twice logs a warning — both handlers fire and both call preventDefault(), which otherwise produces no visible symptom.

useSearchShortcut still exists as an alias for useShortcut('mod+k', …), kept so existing foundations keep working. Prefer useShortcut in new code: it says the same thing without implying the framework chose the key.

useSearch

Search the site. Which engine answers is a deployment choice — a local index the browser downloads, or an endpoint the host serves — and a component is written once against both.

import { useSearch } from '@uniweb/kit'

function SearchBox({ website }) {
  const { query, results, total, isLoading, isEnabled } = useSearch(website)

  if (!isEnabled) return null

  return (
    <div>
      <input onChange={(e) => query(e.target.value)} />
      {isLoading && <span>Searching…</span>}
      {total !== null && <span>Showing {results.length} of {total}</span>}
      {results.map((r) => (
        <a key={r.id} href={r.href}>{r.title}</a>
      ))}
    </div>
  )
}
Returns
resultsmatches, capped at limit (default 10)
totalhow many matched before the cap — the 47 in "showing 10 of 47". null when the active provider cannot say
isLoadinga query is in flight
errorthe Error from a failed search, or null
lastQuerythe text of the most recent query
isEnabledwhether search works on this site — the same answer as isSearchEnabled()
query(text, options?)run a search (debounced); resolves with the results array
clear()drop results and reset
preload()warm the index ahead of first use — a no-op for providers with nothing to warm

total is null when unknown, and that is a normal state rather than an error. A local index always knows the count; an endpoint knows it only if it reports one. Whether a count arrives is a property of how the site is deployed, not of its content — so render the count conditionally and the results unconditionally.

⚠️ total is also null when a type: or route: filter narrowed the results locally. A server that does not apply those filters counted a different set, and reporting its number beside a narrowed list would read as "showing 1 of 47" next to a filter that produced the 1 — consistent and wrong.

Every result carries a guaranteed core — id, type, route, href, title, pageTitle, excerpt, snippetHtml — so a component may render those unconditionally. Everything else (matches, item, group, sectionId, …) is provider-optional and null where unavailable, for the same reason total is. snippetHtml contains <mark> elements: render it through kit's SafeHtml, never as text.

For a count outside React, createSearchClient(website).queryWithTotal(text, options) returns { results, total }; plain query() returns the results array.


block.dataLoading and block.dataError

A section's data arrives in content.data, under the keys its component declares in meta.js data:. Two properties on the block say where a fetch stands:

import { DataPlaceholder } from '@uniweb/kit'

function ArticleList({ content, block }) {
  if (block.dataLoading) {
    return <DataPlaceholder lines={4} />
  }
  if (block.dataError?.articles) {
    return <p>Could not load articles.</p>
  }

  const articles = content.data.articles ?? []
  return <ArticleGrid articles={articles} />
}
PropertyValue
block.dataLoadingtrue while a fetch the section waits on is in progress
block.dataErrornull, or an object keyed like content.data holding each failed fetch's message — { articles: 'HTTP 502: Bad Gateway' }

A declared key is null while its fetch is pending, when it failed, and when nothing on the page fills it; [] is an answer with no records. The runtime re-renders the section when the fetch completes. See Data Fetching → What a section receives.


useWholeRecord

Fetch one record whole, on demand, when a section holds less than the record — a list carries each record's brief, on a static site and on a host alike, and an external query may list summaries and name one record's request with record:. A section on a parametric page whose key is declared whole: true already receives its record whole (Component Metadata → Briefs or whole records); this hook is for everywhere else: a hover card, a modal, an expanding row.

import { useState } from 'react'
import { useWholeRecord } from '@uniweb/kit'

function ArticleCard({ article }) {
  const [open, setOpen] = useState(false)
  const { data: full, loading, error } = useWholeRecord(open ? article : null, {
    query: 'articles',
  })

  return (
    <div>
      <h3>{article.title}</h3>
      <button onClick={() => setOpen(true)}>Read more</button>
      {open && (loading ? <Spinner /> : <ArticleBody article={full} />)}
    </div>
  )
}
ArgumentDescription
recordA record the section received. null or undefined skips the fetch
options.queryThe name of the query the record came from. Required with a record
options.paramThe record field its address is built from. Defaults to the one the query's parametric page matches, else slug

Returns { data, error, loading }. data is the record whole, as stored — each section under its own name, the brief's included (full.brief.title, full.body.content). When the query has no separate source for one record — an external query with no record: — data is the record you passed in, so a component can call the hook unconditionally. It shares the cache with section fetches.


useQueryable

Read the queryable: declaration of a query — the fields a site says a reader may filter on — to render filter controls.

import { useQueryable } from '@uniweb/kit'

function MemberFilters() {
  const queryable = useQueryable('members')
  if (!queryable) return null

  return Object.entries(queryable).map(([field, def]) => (
    <FilterControl key={field} field={field} def={def} />
  ))
}

Returns the declaration as the site wrote it, or null when the query declares none. Kit ships no filter UI: the foundation renders controls for the types it supports and composes a where-object from the reader's choices.


useFetched / useCacheEntry

Low-level hooks for a component that fetches an address of its own — a component that knows its endpoint, as described in Component Data Patterns. They go through the site's fetcher and share its cache, so a request another component already made is answered from memory.

import { useFetched } from '@uniweb/kit'

function ProductCard({ params }) {
  const { data, error, loading } = useFetched({
    url: params.productUrl,
    transform: 'data.product',
  })
  if (loading) return <Skeleton />
  if (error) return <p>{error}</p>
  return <Product {...data} />
}

useFetched(request) dispatches the request and returns { data, error, loading }, aborting when the component unmounts. useCacheEntry(request) observes the same cache entry without fetching: null until someone fills it, then { data, meta }. Pass null to either to skip. A request takes url, and optionally transform, method and body.


useFormSubmit

Submit a form, with the idle → submitting → success | error lifecycle. The destination comes from the site's host or from its own services.submit address, so a component never names an endpoint.

import { useFormSubmit } from '@uniweb/kit'

const { submit, status, error, canSubmit } = useFormSubmit({
  block,
  context: { formId: 'contact' },
  summary: (v) => ({ title: v.name, subtitle: v.email }),
})
Returns
status'idle' · 'submitting' · 'success' · 'error'
errorthe Error from a failed submission, or null
responsethe endpoint's reply on success, or null
canSubmitwhether this site declares a destination
canUploadFileswhether attachments can be delivered — check before rendering a file input
submit(values, overrides?)send; rejects on failure
reset()back to idle

useFormValues

Hold the state of a form an author designed — a yaml:form block at content.data.form. A form-rendering component is the inverse of every other one: it doesn't declare the fields, it receives them and draws whatever it's given. This owns the part that is identical in every such component, so a foundation writes only the controls.

import { useFormValues, useFormSubmit, valueAt } from '@uniweb/kit'

const { controls, values, setValue, missing, formData, files } =
  useFormValues(content.data.form)
const { submit, canSubmit } = useFormSubmit({ block })

{controls.map((c) => (
  <MyControl key={c.path} control={c}          // branch on c.type — your design
             value={valueAt(values, c.path)}
             onChange={(v) => setValue(c.path, v)} />
))}
<button disabled={!canSubmit || missing.length > 0}
        onClick={() => submit(formData, { files })}>Send</button>
Returns
controlsevery control, depth-first, each with the dotted path its value lives at
valueswhat your inputs bind to — holds File objects so a file input can show its selection
setValue(path, v)set by dotted path ('email', 'address.street')
reset()back to the declared defaults
missingpaths of required controls still empty — computed, not enforced
formDatawhat you submit: JSON-safe, file controls omitted
files[{ file, field }] for submitForm, each tagged with its control

valueAt(values, path) reads a value back out — the companion of setValue, exported because every control-rendering loop needs it. ⚠️ values is nested, so values[c.path] does not work: a control at address.street lives at values.address.street, and indexing by the dotted path returns undefined for every nested control — inputs render blank and their answers submit empty, with nothing failing. Top-level controls happen to work either way, which is what makes the mistake survive a first test.

Submit formData, not values. They differ for one reason that would otherwise be a silent, success-shaped failure: submissions are sent as JSON, and a File inside them serializes to {} — an attachment that looks sent and arrives empty. File controls are therefore omitted from formData and ride in files, each tagged with the control it came from so an endpoint can tell two file inputs apart.

missing is computed, not enforced — the same line canSubmit draws. Whether an incomplete form disables the button, shows a message, or submits anyway is your design. Empty means undefined, null, '' or []; a false boolean is a value, so a required checkbox left unticked isn't "missing" — "must be ticked" is a stronger rule that belongs to the component that knows it's a consent box.

A container control (type: group with children) nests: its answers appear under its own key in values and formData, and paths are dotted throughout.

Option
blocksupplies section type / id and page id / label as submission context
contextyour own identifiers (formId, …); wins over what block supplies
summary{ title, subtitle, tag? } or (values) => that — a readable digest
files (per call)Files or { file, field } pairs — sent as bytes after the answers

The framework never invents an endpoint, but a host may supply one — a site on Uniweb Cloud asks for form handling with submit: true under services: and names no address. canSubmit is false only when neither the host nor an address of the site's own provides a destination; render no form rather than posting a visitor's answers nowhere. Companion utilities submitForm() and resolveSubmitTarget(website) do the same thing without React.

Attachments go separately from the answers, so bytes never ride inside the JSON: the manifest is derived from the files you pass, then each file's bytes are sent, then a finalize. Pass them per call and read canUploadFiles at render time, not on submit — a file input someone has already used is too late to withdraw.

await submit(values, { files: [{ file, field: 'photos' }] })
// → { submissionId, filesUploaded: 1, filesRecorded: 1, totalSizeBytes: 4096 }

filesRecorded is the endpoint's own count of what reached storage. When it is lower than what was sent, the call throws instead of resolving — every upload can return 2xx and one can still be absent.

A failure after the first request names what landed — "submission sub-3 was recorded, but uploading 'big.pdf' failed" — so a form can tell a visitor their message arrived and their attachment did not, rather than reporting a flat failure or a false success.


Service predicates

One per service, no arguments. Call one before rendering UI for that service; false means draw nothing — not a disabled control, not an explanation.

import { isSearchEnabled, isSubmitEnabled, isBackendEnabled } from '@uniweb/kit'

if (!isSearchEnabled()) return null
predicatetrue when
isSearchEnabled()any provider answers search — a server, or the prebuilt index a static site ships
isSubmitEnabled()form submissions have somewhere to go
isBackendEnabled()the site has its own backend, the backend service — accounts, per-visitor data. Also exported by @uniweb/api
isAssistantEnabled()an assistant surface answers
isTrackingEnabled()analytics events have somewhere to go

Each is a synchronous read of the site's own configuration — nothing to await, nothing to retry. They resolve the active website themselves and delegate to website.isServiceEnabled(name), which is also how you ask about a service kit ships no predicate for.

⛔ isSearchEnabled() is not "is there a search service." Search is the one service with a local provider, so the predicate is true on a static site that carries an index and no server at all. Gating a search box on anything narrower hides it wherever search works without a host.

The hooks that draw a feature return the same answer as a field — useSearch().isEnabled, useFormSubmit().canSubmit — so a component already using one needs nothing extra. uniweb doctor warns when a component calls one of those hooks and never reads its gate, because a control drawn for a service the site does not have is permanently dead. See Site Services.


resolveService

Where a named service lives for this site — search, form submission, an assistant, or anything a foundation defines.

import { resolveService } from '@uniweb/kit'

const { url, source } = resolveService(website, 'assistant')
if (!url) return null // this site has no assistant — render nothing, or degrade

To decide only whether to draw, a service predicate is shorter — isAssistantEnabled(). Reach for resolveService when you need the address itself.

Resolution is the same for every service: what the host offers (config.services.assistant in the served payload), then the site's own entry (services.assistant in site.yml) for anything the host does not provide, then neither. source says which tier answered — the thing to check when a value you set appears not to be taking effect:

urlsourcemeans
an address'host'the host offers it
an address'site'the site declared it, and no host offers it
null'site'the site switched it off — false or { enabled: false } — and no host offers it
null'host'a host is answering and does not offer it
nullnullnobody declared anything — no host is speaking

The last two both mean "no address", and a caller that only reads url treats them alike — correct for almost every service. They differ for a caller with a fallback of its own, and search is the one that has one: a host that publishes a services block is stating what it offers, so a name absent from that block is declined exactly as a name present with no address. Without that rule, "this host offers analytics and not search" and "there is no host at all" would be the same value, and a site would fall back to an index its host never built.

url is the whole answer, and there is deliberately no explanatory string. Absence is a rendering decision, not a message: no assistant endpoint → draw no Ask-AI affordance; no submit endpoint → draw no form, or degrade to something that still serves the visitor. A visitor has no stake in which services the operator provisioned, and any wording for that state would be ours to invent, in one language, bypassing the site's own localization. Text a visitor should read is site content — authored and localized — never a string a service layer supplies.

A declaration is a string or { endpoint }. Absolute URLs (https://…, //host/…) pass through — a service on another origin is not the site's to relocate — and a root-relative (/forms) or bare (_search) endpoint is joined to the site's base path, once.

The name is open. The framework ships clients for the services it implements and resolution for anything, so a foundation can define its own and a host can fill it with no framework change. resolveServiceUrl(endpoint, basePath) is the join on its own.

See Receiving Form Submissions.


Data Classes (from @uniweb/core)

You typically access these through useWebsite() or the block prop. Direct import from @uniweb/core is rarely needed.

Website

The website instance provides access to site-wide data and navigation.

function Header({ block }) {
  const website = block.website

  // Site identity
  console.log(website.name)         // 'My Site'
  console.log(website.description)  // 'Site description'

  // Pages
  const pages = website.getPageHierarchy({ for: 'header' })
  const allPages = website.pages

  // Locales (for language switcher UI)
  // Note: Content arrives already localized - no translation lookup needed
  if (website.hasMultipleLocales()) {
    const locales = website.getLocales()    // All locale objects
    const active = website.getActiveLocale() // Current locale code
    const url = website.getLocaleUrl('es')   // URL for switching
  }

  // Search
  if (website.isServiceEnabled('search')) {
    // Show search UI
  }
}

Methods

MethodReturnsDescription
getPageHierarchy(opts)arrayGet pages for navigation
getBranchHierarchy(opts)arrayGet pages for one branch of the site
getLocales()arrayGet all locale objects
getActiveLocale()stringGet current locale code
getLocaleUrl(code)stringGet URL for a locale
hasMultipleLocales()booleanCheck if multilingual
isSearchEnabled()booleanCan this site be searched — by a server or the prebuilt index. In a component, prefer isSearchEnabled() from kit
isServiceEnabled(name)booleanIs there a provider for service name? The one implementation behind kit's service predicates; call it directly for a service kit ships no predicate for
isVersionedRoute(route)booleanCheck if route is versioned
getVersionScope(route)stringGet version scope for route
getVersionUrl(version, route)stringCompute versioned URL

getPageHierarchy Options

website.getPageHierarchy({
  for: 'header',        // layout area name — checked against each page's hideIn
  nested: true,         // Include children (default: true)
  includeHidden: false  // Include hidden pages (default: false)
})

for: names the layout area the menu is being built for, and it is tested against each page's hideIn: list. Pass the area you are actually filling — 'header' for the site menu, 'left' for a sidebar — so an author can keep a page in one and out of the other. Any area name works, including ones a foundation invents.

Returns:

[
  {
    id: 'about',
    route: '/about',
    title: 'About Us',
    label: 'About',
    description: 'Learn about us',
    order: 2,
    hasContent: true,
    children: [...]
  }
]

getBranchHierarchy Options

A sidebar shows one branch of a site, not all of it. Give it the active route and it answers that branch's pages.

website.getBranchHierarchy({
  route: location.pathname,  // the active route
  for: 'left',               // layout area, as above
  includeHidden: false
})

Under /docs/reference/cli it returns the documentation tree; under /blog/a-post it returns the blog's. When the route matches no branch — the site root, usually — it returns the whole hierarchy. Pages come back in the order the build settled on, so there is nothing to sort.

Pair it with useHeadings for the two halves of a documentation shell.

Page

Access page metadata and layout configuration.

function Layout({ block }) {
  const page = block.page

  // Identity
  console.log(page.route)       // '/about'
  console.log(page.title)       // 'About Us'
  console.log(page.description) // 'Learn about our company'

  // Layout flags
  if (page.hasHeader()) { /* render header */ }
  if (page.hasFooter()) { /* render footer */ }

  // Versioning
  if (page.isVersioned()) {
    const version = page.getVersion()
    const versions = page.getVersions()
  }
}

Properties

PropertyTypeDescription
routestringPage route path
titlestringPage title
descriptionstringPage description
labelstringShort nav label
idstringStable page ID
layoutobjectLayout flags
sectionsarrayPage sections (blocks)
websiteWebsiteParent website

Methods

MethodReturnsDescription
hasHeader()booleanShould render header?
hasFooter()booleanShould render footer?
getAreaBlocks(name)Block[]Get blocks for a named layout area
getLayoutAreas()objectGet all layout areas as { name: Block[] }
getLayoutParams()objectGet layout params from page.yml
isVersioned()booleanIs page in versioned scope?
getVersion()objectCurrent version info
getVersions()arrayAll versions in scope
getVersionUrl(id)stringURL for version switch

Block

The block represents a rendered section.

import { ChildBlocks } from '@uniweb/kit'

function Hero({ content, params, block }) {
  // Navigation to related objects
  const page = block.page
  const website = block.website

  // Block identity
  console.log(block.id)   // 'hero'
  console.log(block.type) // 'Hero'

  // Child blocks (for composition)
  if (block.hasChildBlocks()) {
    return <ChildBlocks from={block} />
  }
}

Properties

PropertyTypeDescription
idstringSection ID
typestringComponent name
pagePageParent page
websiteWebsiteParent website
childBlocksarrayNested blocks (file-based children)
insetsarrayInline @Component references (separate from childBlocks)
dataobjectThe section's data — the same object as content.data
dataLoadingbooleanA fetch the section waits on is in progress
dataErrorobject | nullFailed fetches' messages, keyed like content.data
hasBackgroundbooleanEngine renders a background behind this section
themeNamestringColor context (light, medium, dark)
stateanyPersistent component state

Methods

MethodReturnsDescription
hasChildBlocks()booleanHas nested sections?
getInset(refId)Block|nullFind an inset by its refId

Component Props

Every foundation component receives these props:

function MyComponent({ content, params, block }) {
  // content - Parsed markdown content
  const { title, paragraphs, links, images, items, data } = content

  // params - Frontmatter parameters (with defaults from meta.js). The section's own
  // settings — theme, background, grid, vars — are not among them: read them from `block`
  const { variant, layout } = params

  // block - Block instance for navigation
  const { page, website } = block

  // data - the keys the component declares in meta.js `data:` — fetched
  // records, or a tagged data block's value; null when nothing fills a key
  const { email, message } = data.contact || {}
}

Content Shape

The runtime guarantees this structure (empty values if not in content):

content = {
  // Headings
  title: '',
  pretitle: '',
  subtitle: '',
  headings: [],

  // Body content
  paragraphs: [],
  links: [],
  lists: [],
  quotes: [],

  // Media
  images: [],
  icons: [],
  videos: [],
  insets: [],        // Inline @Component references

  // Structure
  items: [],         // Child content groups
  headings: [],      // Overflow headings

  // Data
  data: {},          // The declared keys: fetched records, tagged blocks

  // Document order
  sequence: []       // All elements in order
}

Params with Defaults

Param defaults from meta.js are automatically applied:

// meta.js
export default {
  params: {
    variant: { type: 'select', options: ['plain', 'cards'], default: 'plain' },
    columns: { type: 'number', default: 3 }
  }
}

// Component receives merged params
function Grid({ params }) {
  const { variant, columns } = params
  // variant = 'plain' if not specified in frontmatter
  // columns = 3 if not specified
}

DataPlaceholder

A ready-made loading placeholder for sections waiting on runtime data. Renders animated pulse bars.

import { DataPlaceholder } from '@uniweb/kit'

function EventGrid({ content, block }) {
  if (block.dataLoading) {
    return <DataPlaceholder lines={5} />
  }

  return <div>{/* render events */}</div>
}

Props

PropTypeDefaultDescription
linesnumber3Number of placeholder bars to render
classNamestring''Additional CSS classes

Uses animate-pulse and the --border CSS variable for styling. Includes role="status" and aria-label="Loading" for accessibility.

Visual

Renders one visual: an item of the section's media slot by its kind, or the first of the candidates you pass. From @uniweb/kit.

import { Visual } from '@uniweb/kit'

// meta.js: content: { title: 'Headline', media: 'Photo, video or diagram [1]' }
function SplitContent({ content, block }) {
  return (
    <div className="flex gap-12">
      <div className="flex-1">
        <h2 className="text-heading">{content.title}</h2>
      </div>
      <Visual media={content.media} block={block} className="flex-1 rounded-lg" />
    </div>
  )
}

Handed content.media, it renders the first item — the first image, video or embedded component the author placed, whatever its kind. Handed one item, it renders that one, which is how a gallery renders every item in the author's order:

{content.media.map((item, i) => <Visual key={i} media={item} block={block} />)}

Props

PropTypeDefaultDescription
mediaobject or array—A content.media item — { kind: 'image' | 'video' | 'inset', … } — or the list, for its first item
blockBlock—The section's block. An embedded component (kind: 'inset') resolves through it
insetBlock—Inset Block instance (from block.insets or block.getInset())
videoobject—Video object with src property
imageobject—Image object with src and alt properties
classNamestring—CSS classes for the visual container
fallbackReactNodenullFallback when no visual is found

Without media: the first of the candidates you pass, in the order inset > video > image — whatever order the author placed them in.

A slot narrowed to one type (media: { types: ['image'] }, or image:) can render with <Image> directly. A slot that takes embedded components must render them — <Visual> does; kit's <Media> is a video player.

ChildGrid

Lays a section's child sections out in the columns the section chose with its grid key — 3 (equal columns) or '40/60' (relative widths) — one of the layouts the component offers in meta.js children.grid (Grid layouts). From @uniweb/kit.

import { ChildGrid } from '@uniweb/kit'

function Grid({ block, params }) {
  return <ChildGrid from={block} fallback={3} headerRow={params.headerRow} className="gap-6" />
}

Props

PropTypeDefaultDescription
fromBlock—The section whose childBlocks are laid out
fallbacknumber | stringnullThe layout when the section chose none, or chose something that is not a layout — list it first in children.grid
headerRowbooleanfalseThe first child spans every column
classNamestring—Classes on the grid; replaces the default gap-8
cellClassNamestring—Classes on each child's cell

One column on narrow screens; from md, a layout of up to two columns as chosen and a wider one as two equal columns; from lg, the chosen layout. With no layout at all, the children stack. The columns are computed by gridTemplate from @uniweb/schemas/grid, so a visual editor drawing with the same function draws what renders.

Each child renders as a section, in a div: its own theme: and background: apply.

SectionBackground

The background the author set on a section, drawn by the runtime's own renderer — for a component that places it itself. The runtime draws a section's background behind every section; a component that paints its own declares background: 'self' in meta.js, and the runtime then draws none. With SectionBackground it can still draw the author's image, video, gradient or color, with its overlay, wherever it wants. From @uniweb/kit.

import { SectionBackground } from '@uniweb/kit'

function Hero({ content, block }) {
  return (
    <section className="relative">
      <SectionBackground block={block} />
      <div className="relative z-10">…</div>
    </section>
  )
}

It fills the nearest positioned ancestor, so place it inside an element with position: relative, before the content. It draws nothing for a section without a background.

PropTypeDescription
blockBlockThe section — its block.background, normalized by the runtime
classNamestringClasses on the background layer

Render vs Prose

<Prose> is <Render> inside a typography container: one walk over the same content, plus the prose classes that style what it emits. Both read the parsed sequence (content.sequence) — or parse the ProseMirror document they are given, or the block's — and both render every kind of element a section holds, tables, callouts, insets, icons and math included. Tagged data blocks are left out of both: a component reads them from content.data.

<Render><Prose>
EmitsThe elements alone — no wrapper, no classesThe same elements, in a prose container whose size you pick
Use whenYou supply the container: your own type system, or a prose element of your ownYou want kit's prose typography with nothing more to write
// A section type that lays out what the author wrote with its own type system
import { Render } from '@uniweb/kit'

export default function Section({ block }) {
  return (
    <div className="doc-body">
      <Render block={block} />
    </div>
  )
}
// The section as written, with prose typography — and its data rendered your own way
import { Prose } from '@uniweb/kit'

export default function Lesson({ content, block }) {
  return (
    <>
      <Prose content={content} block={block} />
      {content.data.quiz && <Quiz data={content.data.quiz} />}
    </>
  )
}

The sequence is the section as written, headline included — so a component that draws content.title itself renders it twice if it also hands the whole sequence to either one.

Both need the prose stylesheet

<Prose> emits a prose container, and <Render> output is normally placed inside one. Your foundation's stylesheet supplies the rules with one line — there is nothing to install:

/* your foundation's styles.css, after the Tailwind import */
@import "@uniweb/kit/prose-tokens.css";

Without it the markup is right and completely unstyled — lists lose their bullets and numbers, headings and spacing their typography. The foundation build warns when it renders <Prose> or <Article> with no prose styles in its CSS, and uniweb doctor flags it too.

The rules are written for what <Render> emits — a tight list drawn without paragraphs, a button that says it is one, an image's credit, a document's file card — and take every color from the section's theme tokens, so long-form text follows the site's theme.yml and the visitor's light or dark scheme with nothing more to do. A prose container may sit inside another: it re-points no color, so its text is --body as the outer one's is, and a bare prose draws at the base step whatever step surrounds it.

Size and rhythm. size — sm, base, lg (the default), xl, 2xl — picks a step. The classes are prose-sm … prose-2xl, and they work behind a breakpoint (md:prose-lg). Each step is a ratio of the site's reading size, which a site sets in theme.yml with the rest of the scale — prose-size, prose-leading, prose-measure, prose-flow and prose-h1 … prose-h4 (Long-form text). Unset, each step keeps Tailwind Typography's tuning for its size — line heights, spacing, heading metrics, code blocks, tables and figures — and inline code is a chip at 0.875em of its text at every step. A code block takes the step's size and line height and keeps its own padding, corners and colors, in one box that stays put when its highlighting loads. <Prose> and <Article> fill their box (max-w-none), so prose-measure reaches a prose element you write without it; set their width on the box around them. To declare the variables in your foundation, described so an editor can offer them, spread kit's declarations into main.js:

import { proseVars } from '@uniweb/kit/prose-vars'

export const vars = {
  ...proseVars,
  // your foundation's own
}

Overriding. Every rule sits beneath Tailwind's utilities, so a utility class wins — on the container (<Prose className="text-lg">), or through an element variant: prose-headings:tracking-tight, prose-a:no-underline, and one for each element (prose-p:, prose-code:, prose-pre:, prose-table: …). prose-a: styles links and leaves a content button ([data-role="button"]) its own look. not-prose opts a subtree out. There are no palette modifiers and no prose-invert: the colors are the theme's, and uniweb doctor flags any it finds in a foundation's components.

Both stamp an id on each heading, from the same generator, so useHeadings and any anchor link agree with whichever one rendered.

Both lay out an author's placement

An author places an image, a video or a table with {placement=…} — left, right, wide or full (Placement). Both write it as data-placement on the block's box, and prose-tokens.css lays the four out: left and right beside the text at --prose-side-width (half the column), wide past the column by --prose-wide-bleed on each side, full across the nearest size container. Mark the element your sections sit in with Tailwind's @container class (container-type: inline-size) so full spans it rather than the viewport, or set --prose-full-width. A component built on them declares sequence: { placement: true } in its meta.js, so a visual editor offers its authors all four (Sequence).


Utilities

cn

Merge Tailwind classes, resolving conflicts so a later class wins over an earlier one. Falsy values are dropped, which makes conditional classes read cleanly.

import { cn } from '@uniweb/kit'

cn('px-4 py-2', isActive && 'bg-primary', className)

Order matters, and one pair is surprising. A font-size class overrides a preceding leading-*, because in Tailwind a size class like text-lg sets both font size and line height. So cn() treats a later text-* as replacing an earlier leading-* — and silently drops it:

cn('leading-[1.1] text-[clamp(2rem,5vw,4rem)]')  // → 'text-[clamp(2rem,5vw,4rem)]'  ⚠️ leading gone
cn('leading-tight text-4xl')                     // → 'text-4xl'                      ⚠️ leading gone
cn('text-4xl leading-tight')                     // → 'text-4xl leading-tight'        ✅

This bites hardest when the size comes from a lookup and the leading is in a shared base string, because the base is written first. Two ways out — put the size before the leading, or fold the leading into the size with Tailwind's slash syntax:

const TITLE_SIZE = { hero: 'text-[clamp(2.8rem,6.5vw,4.5rem)]/[1.1]' }   // size and leading in one class
<H1 className={cn('font-bold tracking-tight', TITLE_SIZE[params.layout])} />

The same rule applies to any pair Tailwind fuses into one utility. When a class you passed doesn't show up in the DOM, check whether a later class in the same cn() owns that property too.

getLocaleLabel

Get display name for a locale.

import { getLocaleLabel, LOCALE_DISPLAY_NAMES } from '@uniweb/kit'

// From locale object with label
getLocaleLabel({ code: 'es', label: 'Spanish' })  // 'Spanish'

// From locale object without label
getLocaleLabel({ code: 'es' })  // 'Español' (from built-in names)

// From string
getLocaleLabel('es')  // 'Español'

// A code the built-in names don't list — its own name, where the platform knows it
getLocaleLabel('ca')  // 'Català'

// Unknown code
getLocaleLabel({ code: 'xx' })  // 'XX'

// Access built-in names directly
console.log(LOCALE_DISPLAY_NAMES.fr)  // 'Français'

splitContent

Split parsed content at --- dividers into separate regions. Each region has its own sequence slice; grouped fields (title, paragraphs, items) are preserved from the original. Use this when the component needs to route different content regions to different UI.

import { splitContent } from '@uniweb/kit'

function Lesson({ content, block }) {
  const [lesson, challenge] = splitContent(content)
  return (
    <div>
      <Prose content={lesson} block={block} />
      <aside className="bg-card p-6 rounded-lg">
        <Prose content={challenge} block={block} />
      </aside>
    </div>
  )
}

Returns a single-element array ([content]) if no divider exists, so destructuring always works.

Note: This is different from Loom's splitAtDividers, which splits raw ProseMirror nodes before parsing for data-driven iteration. splitContent splits after parsing for UI layout purposes. See "Dividers — Content Boundaries" in the authoring guide.

Icon Component

Renders icons from multiple sources: library icons, URLs, direct SVG, or built-in icons.

import { Icon } from '@uniweb/kit'

// String ref (recommended for library icons)
<Icon icon="lu-house" />         // dash format
<Icon icon="lu:house" />         // colon format
<Icon icon="lucide:house" />     // full library name

// Explicit library + name
<Icon library="lucide" name="house" />

// From URL
<Icon url="/icons/custom.svg" />

// Direct SVG content
<Icon svg="<svg>...</svg>" />

// Built-in icons (no library needed)
<Icon name="check" />
<Icon name="close" />

Props

PropTypeDefaultDescription
iconstring/object—String ref ("lu-house"), URL, or { library, name } object
librarystring—Icon library shortcode (lu, hi, fi, etc.)
namestring—Icon name within the library
urlstring—URL to fetch SVG from
svgstring—Direct SVG content
sizestring'24'Icon size in pixels
colorstring—Icon color (defaults to currentColor)
preserveColorsbooleanfalseKeep original SVG colors instead of using currentColor
classNamestring—Additional CSS classes

The string icon prop is the most concise way to use library icons. The same string formats work in markdown (![](lu-house)), in YAML data (icon: lu:house), and in JSX (<Icon icon="lu-house" />).

parseIconRef

Parse an icon string reference into its library and name parts.

import { parseIconRef } from '@uniweb/kit'

parseIconRef('lu-house')       // { library: 'lu', name: 'house' }
parseIconRef('lu:house')       // { library: 'lu', name: 'house' }
parseIconRef('lucide:house')   // { library: 'lucide', name: 'house' }
parseIconRef('not-an-icon')    // null (prefix not a known library)

Useful when you receive icon strings from structured data (content.data) and need to pass them to components that expect separate library/name props.

Overlay Component

Render a modal, command palette, drawer or toast above the page, from anywhere in the tree.

import { Overlay } from '@uniweb/kit'

function SearchDialog({ isOpen, onClose }) {
  if (!isOpen) return null

  return (
    <Overlay onClose={onClose} className="items-center">
      <div role="dialog" aria-modal="true" aria-label="Search" className="w-full max-w-2xl rounded-2xl bg-card">
        …
      </div>
    </Overlay>
  )
}

Why not a fixed inset-0 z-… div

Because that does not work, and the reason is invisible.

The runtime gives each layout area its own view-transition-name so the header, rails and body animate independently, and that makes every area a stacking context. A fixed dialog inside an area is still placed against the viewport — what the area confines is where it paints in the stack. Areas sit above the body, so a dialog rendered from a section paints under the header and footer, whatever z-index it carries; one rendered from your Header paints under a footer or sidebar on the header's layer that comes after it. Raising the number looks like it should help and never does, because the elements are not competing in the same stacking context.

Overlay renders into document.body, outside every area wrapper, where the z-index means what you expect.

It contains focus, so aria-modal is true

A modal overlay moves focus in on open, cycles Tab within it, pulls focus back if it escapes, marks the rest of the page inert, and restores focus to whatever opened it when it closes. On by default.

This matters more than it sounds. aria-modal="true" tells assistive technology that everything outside the dialog is unreachable. Without containment that is false in the most literal way: a keyboard user Tabs straight out into the page behind and lands on controls a screen-reader user has been told do not exist. Nothing errors, and the people it fails are the least likely to be in the room when it ships.

You still write role="dialog" aria-modal="true" on your own box — it is your element — but the promise is now backed.

modal (default true) is the master switch, because the cluster it governs only makes sense together: scrim, focus trap, scroll lock, inert background. A dialog, palette, drawer or lightbox wants all of it.

modal={false} is the other real case — a toast or notification. It escapes the stacking context and does nothing else: no scrim, no trap, no scroll lock, and pointer events pass through so the page stays usable. Content opts back in with pointer-events-auto.

<Overlay modal={false} className="items-end justify-end p-6">
  <div className="pointer-events-auto rounded bg-card p-4">Saved</div>
</Overlay>

The scrim is a default, not a constraint

A modal overlay dims the page for you. className is applied last and Tailwind conflicts resolve in your favour, so every adjustment is a plain override:

You writeResult
nothingthe default dimmed scrim
bg-primary/10your colour — a theme token works as readily as a fixed one
bg-transparentno scrim
backdrop-blur-smkept alongside the default
items-centercontent centred instead of top-aligned

The layer is a flex container, so items-* and justify-* place your content. The box itself — its width, radius, background, animation — is your foundation's design, the same way kit ships no layout.

Props

PropTypeDefaultDescription
childrennode—The overlay content
onClosefunction—Called on Escape and on a scrim click. Omit for an overlay that dismisses some other way
modalbooleantrueBlocks the page: scrim, focus trap, scroll lock, inert background
classNamestring—Classes for the scrim / positioning layer, applied last
zIndexnumber | string100Stacking order of the layer
initialFocusref | string | falsefirst focusableWhat to focus on open; false leaves focus alone
returnFocusbooleantrueRestore focus to whatever opened it
trapFocusboolean= modalTurning this off on a modal overlay makes aria-modal a false claim — prefer modal={false}
lockScrollboolean= modalPrevent the page behind from scrolling
closeOnEscapebooleantrue
closeOnScrimClickbooleantrue

Anything else is spread onto the layer.

Details worth knowing

  • Escape is handled on the capture phase, so it still closes while an input inside the dialog has focus.
  • Only a click on the scrim itself closes. A click that bubbled out of your content does not, so a text selection ending outside the box is safe.
  • Nesting works. A confirm dialog over a settings dialog: only the topmost closes on Escape and traps focus, and the inner one closing does not unlock scrolling or restore the page underneath the outer one.
  • Nothing is emitted when server-rendered, so a prerendered page carries no scrim.

When not to use it

An anchored popover, dropdown menu or tooltip wants to be positioned relative to its trigger and to leave the page interactive. A full-screen layer with a scrim and a scroll lock is the wrong shape for those — build them in your foundation, positioned normally.

Client-side navigation with page: protocol support.

import { Link } from '@uniweb/kit'

function Navigation() {
  return (
    <nav>
      <Link to="page:home">Home</Link>
      <Link to="page:about#team">Our Team</Link>
      <Link to="/external" target="_blank">External</Link>
    </nav>
  )
}

Access Patterns

import { useWebsite, useRouting } from '@uniweb/kit'

function MyComponent() {
  const { website } = useWebsite()
  const { route } = useRouting()

  // Access locale via website
  const locale = website.getActiveLocale()

  // Use hooks throughout the component
}

From Block Props

function MyComponent({ block }) {
  const website = block.website
  const page = block.page

  // Access via block reference
}

When to Use Which

ScenarioUse
General component logicHooks
Accessing page/website from nested componentsHooks
Simple property access in main componentBlock props
Non-React code (utilities)Block props

See Also