@use "/shell" as *; @use "quark:list" as list; #release-notice { /* opens once: sheet re-runs restart a delay, so gate it on the fact it writes */ &:not([data-did-open]) { @delay 7000 { is-open: ""; data-did-open: ""; } } /* prevent clicks from bubbling to the sheet */ @on mouseup, click (stop-propagation); } provider-fetch[api-url*="package-metas/index.json"][is-success] { $package-indices: prop("provision").body; /* `excom.navGroup` moves a package out of its type's list, into that group */ $ungrouped: list.reject($package-indices.packages, "navGroup"); .package-links { [bind-elements] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "kit-element")); } [bind-element-bases] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "element-base")); } [bind-tools] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "tool")); } /* the standalone libraries: same rows, rendered flat (shell.css) */ [bind-libraries] ul:not(ul ul) { content: iterate(getPackagesByType($ungrouped, "library")); } [bind-library-group] ul:not(ul ul) { content: iterate(list.filter($package-indices.packages, "navGroup", "libraries")); } ul:not(ul ul) > li { $pkg: item.shortName; > spa-a { route-href: "/packages/#{item.shortName}"; } } /* one `content` rule per link: a second one would rewrite the first's text on every pass */ ul:not(ul ul, [bind-libraries] ul) > li > spa-a { content: item.shortName; } [bind-libraries] ul:not(ul ul) > li > spa-a { content: displayName(item.shortName); } /* packages whose docs span several pages: one collapsible group per section */ [bind-sections] { content: iterate(item.docSections); summary { content: item.title; } details > ul { content: iterate(item.docs); spa-a { route-href: "/packages/#{$pkg}/#{item.name}"; content: item.title; } } details:not(:has(spa-a[is-active])) { open: none; } } } /* page titles: the page, then the site. The docs home keeps the title in the document head; the other routes carry theirs in markup */ spa-route[is-active] { /* a site guide */ &[route-href$=":name"] { $title-guide: list.find($package-indices.docs, "name", $route.params.name); document-title: "#{$title-guide.title or $route.params.name} · Nucleus · docs"; } /* a package README */ &[route-href$=":packageName"] { document-title: "#{displayName($route.params.packageName)} · Nucleus · docs"; } /* one page of a package's docs */ &[route-href$=":docName"] { $title-package: list.find($package-indices.packages, "shortName", $route.params.packageName); document-title: "#{docTitle($title-package, $route.params.docName)} · #{displayName($route.params.packageName)} · Nucleus · docs"; } } /* the page's markdown file, for the footer link: the docs home and each guide the index lists, and a package's page whose index entry says `markdown` (build-docs-index sets it; the dev server's index has none, so no link there). siteDocHref and the path check say which URL is the page's own: the 404 and a package's doc pages have none */ > spa-manager[active-url] { $page-path: attr("active-url").split("#").at(0).split("?").at(0); $page-name: if($page-path == SITE_HOME: SITE_HOME_DOC; else: $page-path.split("/").at(-1)); $page-guide: list.find($package-indices.docs, "name", $page-name); $page-package: list.find($package-indices.packages, "shortName", $page-name); $page-markdown: if($page-guide and siteDocHref($page-guide.name) == $page-path: "/docs/#{$page-guide.name}.md"; $page-package.markdown and "#{SITE_BASE}/packages/#{$page-package.shortName}" == $page-path: "/#{$page-package.shortName}.md"); footer [bind-page-markdown] { href: $page-markdown; content: ternary($page-markdown, "Markdown version of this page"); } } } spa-route { $route: prop("provision"); /* paramless routes name their guide in markup (the docs home, `/`); /docs/:name gets it from params */ $route-doc-name: attr("data-doc-name"); } /* the desktop aside and the mobile sheet stamp the same nav template */ [data-site-nav] { details:has(spa-a[is-active]) { /* not kosher */ open: ""; } spa-a[is-active] { aria-current: ""; } spa-a:not([is-active]) { aria-current: none; } } /* mobile sheet: Escape / back gesture while open; a tapped link closes it */ #site-menu { &[is-open] dismiss-watcher { is-active: ""; } &:not([is-open]) dismiss-watcher { is-active: none; } @on click (target: "spa-a") { is-open: none; } } #site-menu-gesture { &:has(> #site-menu[is-open]) { progress-offset: 1; } &:not(:has(> #site-menu[is-open])) { progress-offset: 0; } @on gesture-handler-start { #site-menu { is-scrubbing: ""; } } @on gesture-handler-end { #site-menu { is-open: event.detail.snap == 1; is-scrubbing: none; } } } main { @on copy-source (handle: copySource); } #search-dialog[open] { > include-content { is-active: ""; /* for some reason this is necessary on first render */ @on include-content-did-render (handle: focusInput); } input[type="search"] { /* works on all subsequent opens */ autofocus: ""; } } #search-dialog:not([open]) input[type="search"] { autofocus: none; }

renderable-element

Composition base for Neutron elements that defer rendering a <template> until the right moment. It owns the load + render lifecycle so subclasses only decide when to flip is-active. Used by <include-content>, <spa-route>, and any custom element you compose yourself.

The demos below use <include-content> (the simplest concrete subclass) to exercise behavior that comes straight from this mixin.

Features

  • Template resolution via template-ref in-document selectors or remote URLs
  • Prefetch strategies lazy (default), eager, or idle
  • Configurable render host light DOM, shadow, author iframe, or any selector
  • Cancelable render / unrender parents can wrap updates in view transitions
  • Persistable content keep live subtree state across unrender / render cycles
  • Ready coordination ready-on + delaying-ready for paint-synced reveals

Installation

This package is available in the NucleusKit. Or it can be used by itself:

CDN Package Manager
<script src="https://unpkg.com/@excom/kit-utils@0.3.0/dist/index.umd.min.js"></script>
<script src="https://unpkg.com/@excom/neutron@0.2.0/dist/index.umd.min.js"></script>
<script src="https://unpkg.com/@excom/renderable-element@0.3.0/dist/index.umd.min.js"></script>
npm install @excom/renderable-element
HTML Imports JS / CSS Imports
import { /* … */ } from "@excom/renderable-element";
Peer dependencies (0)

Packages a consumer must install alongside this one. Workspace deps are bundled.

Package Version
View Dist Files

All exports:

.
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.js
  • default: ./dist/index.js
./index.min
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.min.js
  • types: ./dist/index.d.ts
  • import: ./dist/index.min.js
  • default: ./dist/index.min.js
./index.umd.min
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js
./index.umd.min.js
  • types: ./dist/index.d.ts
  • default: ./dist/index.umd.min.js

Usage

Compose RenderableElement into a Neutron class and toggle isActive from whatever signal makes sense — a media query, a websocket message, an experiment flag, etc. Everything else (template fetch, caching, host resolution, lifecycle events) is inherited.

import { Neutron } from "@excom/neutron";
import { RenderableElement } from "@excom/renderable-element";
​
export const MediaGated = Neutron.compose([
  RenderableElement,
  Neutron({
    tag: "media-gated",
    props: {
      mediaQuery: String,
    },
  }),
])
  .onPropChanged("mediaQuery", (el, prev) => {
    prev.mediaQuery && el._mql?.removeEventListener("change", el._sync);
    if (!el.mediaQuery) return { isActive: false };
    el._mql = window.matchMedia(el.mediaQuery);
    el._sync = () => (el.isActive = el._mql.matches);
    el._mql.addEventListener("change", el._sync);
    el._sync();
  });
​
MediaGated.define();
<media-gated media-query="(min-width: 900px)">
  <template>
    <wide-screen-only></wide-screen-only>
  </template>
</media-gated>

Event names are prefixed with the concrete tag (shown as {tag}-… in the API). For <include-content> that means include-content-render, etc.

API Reference

Attributes (11) Role — option is configurable, state is managed by the element (read-only), or hybrid which is both. Role Attribute Type Values Prop Default option bypass-cache
Skip the in-memory response cache (URL template-ref only). bypassCache
boolean false
option host-ref
Where rendered children land. Unset = this element's light DOM. shadow attaches an open shadow root. iframe paints into a child <iframe data-render-host> body (you supply the iframe — useful for sandboxed / third-party document isolation). Any other value is a portal selector. hostRef
string "shadow" | "iframe" | <CSS Selector> null
option persist-content
Reuse the same live nodes across unrender / render (held on _persistedTree) so form values, scroll position, and subtree state survive toggles. persistContent
boolean false
option pre-fetch
When to fetch the template, independent of when it renders. "" aliases eager. idle never runs in a prerender. preFetch
string "" | "eager" | "idle" | "lazy" "lazy"
option ready-on
Event name that marks rendered children "ready". Until it fires, delaying-ready is set so CSS can hide the host for a coordinated paint / view transition. Prerendered content kept at hydration is ready at once, never hidden. readyOn
string <Event Name> null
option template-ref
Source <template> — in-document selector or remote URL. Changing mid-flight aborts and reloads. Can use :scope to relatively select elements: e.g. main:has(:scope) > template templateRef
string <CSS Selector> | <URL> ":scope > template"
hybrid is-active
Master switch. Set to load (if needed) and render; unset to unrender. Drive from visibility, route match, hover, etc. isActive
boolean false
state delaying-ready
Between render and the matching ready-on event (or a failed load). Hook with CSS for coordinated paints / view transitions. delayingReady
boolean false
state did-load
Template resolved at least once. Stays set across is-active toggles so consumers know later paints are warm (URL refs reuse the shared fetch cache in kit-utils). Cleared when template-ref changes or --reload forces a fresh resolve. didLoad
boolean false
state is-error
Latest template fetch rejected (excluding abort). Fires with the error event. isError
boolean false
state is-loading
Template fetch in flight. isLoading
boolean false
Provision (0) This provision property lives on the DOM node. Read / watch it with Quark’s prop("provision"), or listen for the neutron-provision event from app JS. Property Type
Events (7)
Dispatches — events that fires and their default actions. Dispatch Default Action
A "default action" is subsequent logic executed by the element if e.preventDefault() is not synchronously called on the event.
Name {tag}-aborted
Dispatched when an in-flight load / ready wait is canceled because is-active was unset (via startTeardown).

Type RenderableAbortedEvent
Name {tag}-did-render
Dispatched after the template content has actually been placed into the host.

Type RenderableDidRenderEvent
Name {tag}-did-unrender
Dispatched after rendered children have been removed from the host.

Type RenderableDidUnrenderEvent
Name {tag}-error
Dispatched when the template promise rejects with anything other than an AbortError.

Type RenderableErrorEvent
Name {tag}-render
Cancelable. Dispatched when the element becomes active and is about to place template content into the host. event.detail is a thunk that performs the load (if not already loaded) and renders the children, returning a Promise that resolves once the corresponding ready-on event fires (or immediately if ready-on is unset). The promise rejects if the template fails to load, host-ref resolves to no host (nor an author iframe still loading), or the element is torn down mid-flight (startTeardown while loading / delaying-ready). Call preventDefault() to defer rendering and invoke event.detail() later.

Type RenderableRenderEvent
Invokes event.detail() to load (if needed) and render the template into the host.
Name {tag}-unrender
Cancelable. Dispatched when the element becomes inactive and content is already painted. event.detail is a thunk that removes the rendered children. Call preventDefault() to defer the removal. Not fired when teardown cancels an in-flight load — that path emits aborted instead.

Type RenderableUnrenderEvent
Invokes event.detail() to remove rendered children from the host.
Listeners — events that listens for which will trigger subsequent actions. Listener Type Action
Commands — verbs accepts as a command event (HTML Command API): from a <button command commandfor>, an <event-handler command-name>, or a CommandEvent. Command Action --reload Stops waiting for any in-flight load and re-resolves the template, bypassing the cache for URL refs (useful after remote content changes). A URL request is shared by the page, so the one in flight is not cancelled.
Recognized Elements (2) Child or descendant elements are recognized by and relevant to its functionality. Element Relationship Required iframe[data-render-host]
Required when host-ref="iframe". Content paints into iframe.contentDocument.body. Provide your own iframe (e.g. with srcdoc); the element will not create one.
child false
template
Optional immediate <template> child used when template-ref is the default ":scope > template". Not required when template-ref points at a selector or URL elsewhere.
child false
Styles (0)
Classes — optional classes that change the appearance of . Class Description
Variables — public CSS variables for theming . Opt-out:
If you wish to opt-out of these styles on a case-to-case basis, use property all: revert-layer. Valence.css ships this as .unstyled and .unstyled-all for the entire tree.
Variable Syntax Default
Aliases — @custom-selector synonyms so custom elements can share semantic styles. Element aliases match the host tag / .tag-* class; state aliases match attributes (nested under the element alias). Alias Kind Matches
Release notes (3)

0.3.0

  • Add hydration of prerendered content: an element whose host already holds the server's render of the same template does not render again, fires did-render once and is ready at once (never delaying-ready); persist-content keeps those nodes across toggles and pre-fetch="idle" does not run during a prerender
  • Fix an issue where a first mount into a host-ref selector cleared the host and fired did-unrender: what the host holds now stays until the first render replaces it

0.2.0

  • Update a URL template-ref that answers with an error status to set is-error and fire the error event, paint nothing and log one error with the status as its cause; the error page is no longer rendered as content
  • Fix an issue where a failed template load held ready until render-timeout: the promise from render rejects at once and delaying-ready clears
  • Fix an issue where the promise from render never settled and delaying-ready stayed set when host-ref resolved to no host: the promise now rejects at once and delaying-ready clears, while an author iframe still loading keeps waiting

0.1.1

  • Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
View Source

Examples

Choosing a render host

Unset host-ref renders into the element's light DOM. "shadow" attaches an open shadow root for style isolation:

"iframe" paints into a child <iframe data-render-host> you provide — sandbox styles / scripts / document context. Only nodes move: custom elements upgrade there only if the iframe document loads their definitions:

Any other value is a CSS selector — the template lands in whatever element it resolves to:

On a prerendered page the light DOM and a selector host keep their prerendered content; shadow and iframe hosts are not prerendered and render in the browser.

Hooking render with view transitions

render and unrender are cancelable; event.detail is the update thunk. A parent can preventDefault() and run the mutation inside document.startViewTransition() — the same pattern <spa-manager> uses to batch sibling routes.

The thunk's returned Promise resolves when the view is ready (immediately, or when ready-on fires). If is-active is unset while still loading / delaying-ready, teardown rejects that Promise and emits aborted instead of unrender.

Pair with ready-on so delaying-ready stays set until your transition has committed:

<media-gated ready-on="my-app-paint" media-query="(min-width: 900px)">
  <template>...</template>
</media-gated>
media-gated[delaying-ready] {
  display: none;
}

Persisting content across cycles

Once a template resolves, did-load stays set so consumers know later toggles are warm — URL template-refs reuse the shared fetch cache in kit-utils. Without persist-content (the default), each activation re-resolves and imports a fresh clone — subtree state is lost on unrender. With it, the same live nodes are held across toggles:

On a prerendered page the nodes it holds are the prerendered ones.

Loading strategies

pre-fetch controls when the template is fetched, separately from when it is rendered:

<!-- default: fetch on first activation -->
<my-el></my-el>
​
<!-- pre-warm immediately on attribute set -->
<my-el pre-fetch="eager"></my-el>
​
<!-- backfill on idle -->
<my-el pre-fetch="idle" template-ref="/fragments/hero.html"></my-el>

Pair with bypass-cache for revalidation when the element activates multiple times. Invoke the --reload command to force a refresh.

pre-fetch="idle" never runs in a prerender: the template is fetched in the browser.

Beta. The Nucleus Stack is in beta for a few weeks until features are stabilized and optimized.

Thanks — we'll email you when it ships.

Something went wrong. Please try again.