sco-pe
July 10, 2026 · View on GitHub
Scoped navigation for server-rendered apps.
sco-pe is an autonomous custom element. It progressively enhances regular links and forms inside a scoped region: requests are fetched, matching server-rendered HTML is swapped back into the scope, and regular navigation still works without JavaScript.
The public surface is intentionally small: behavior is configured on <sco-pe>, response behavior comes from Scope-* headers, and richer client-side behavior comes from external modules or custom elements.
Goals
- valid, quiet HTML;
- native links and forms first;
- one custom element boundary:
<sco-pe>; - no public
data-scope-*attribute API; - modern
fetch(); - per-scope request cancellation;
- accessible busy, focus, status, and alert behavior;
- external asset loading through
Scope-*headers; - no inline script execution from fetched HTML by default;
- Playwright coverage for real navigation and form flows.
Basic usage
<script type="module" src="/assets/sco-pe.js"></script>
<div id="scope-status" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="scope-alert" role="alert" aria-atomic="true"></div>
<sco-pe id="main" src="/admin/users" history="true">
<!-- Optional server-rendered fallback content. -->
</sco-pe>
Inside the scope, regular links and forms are enough:
<a href="/admin/users?page=2">Next</a>
<form action="/admin/users" method="get">
<input name="q">
<button>Search</button>
</form>
<form action="/admin/users" method="post">
<input name="email" type="email" required>
<button name="save" value="1">Save</button>
</form>
For non-GET actions, prefer normal forms over JavaScript-only button actions:
<form action="/admin/users/12/archive" method="post">
<button>Archive</button>
</form>
Attributes on <sco-pe>
Attributes on <sco-pe> are the public custom element API:
<sco-pe
id="main"
src="/admin/users"
history="true"
select="sco-pe#main"
target="_self"
focus="auto"
scroll="top"
announce="auto"
autosubmit="300"
keep="same-html"
keep-selector="admin-rich-select, admin-map"
transition="fade"
transition-timeout="250"
></sco-pe>
Supported behavior attributes:
src initial URL to load
history true/false, update browser history for safe requests
select selector to extract from full-document responses
target another <sco-pe id> or _self
focus auto|heading|first-error|keep|none
scroll top|keep|none|hash
announce auto|status|alert|none
autosubmit debounce in ms for GET forms inside the scope
scope-swap selector for a single child to replace instead of the whole scope
keep none|same-html
keep-selector optional selector for kept same-html elements
transition none or a CSS mode name, e.g. fade
transition-timeout fallback duration in ms
disabled leave links and forms to the browser (unless exactly "false")
select always extracts the selected element's content, rather than injecting the selected wrapper element.
Confirmation
data-confirm is the one deliberate data-attribute exception. The message belongs to an individual native action, not to the surrounding scope. Put it on a link or form to ask before sco-pe sends the request; a submit button's own value overrides the form's value.
<form action="/admin/users/12" method="post" data-confirm="Delete this user?"><button>Delete</button></form>
The handler is configurable when an application needs a custom dialog:
Scope.configure({ confirmHandler: async (message) => window.confirm(message) });
Submit buttons can use native HTML overrides, including buttons outside the form:
<form id="user-form" action="/admin/users" method="post">
<input name="email">
<button name="command" value="save">Save</button>
<button name="command" value="preview" formaction="/admin/users/preview" formmethod="get">Preview</button>
</form>
<button type="submit" form="user-form" formaction="/admin/users/preview" formmethod="get">
Preview outside the form
</button>
Scroll behavior
By default, user-initiated navigation scrolls the document or scope to the top after the swap:
<sco-pe id="main" scroll="top"></sco-pe>
Supported values:
top scroll the scope/document to the top
keep preserve scroll positions for the document, scope, and scroll containers with stable ids
none do not scroll
hash focus and reveal the URL hash target when present
To preserve a replaced scroll container, give it a stable id. Same-document hash links are not fetched; sco-pe leaves native navigation intact and only focuses the hash target for accessibility.
Autosubmit
Use autosubmit="300" on the scope for live GET filters:
<sco-pe id="users" src="/admin/users" history="true" autosubmit="300">
<form action="/admin/users" method="get">
<input name="q">
</form>
</sco-pe>
Only GET forms are autosubmitted. The URL is updated with history.replaceState() to avoid creating a history entry for every keystroke.
Combine with scope-swap to replace only the result container, keeping the
form intact and avoiding focus loss:
<sco-pe id="catalog" src="/catalog" autosubmit="250" scope-swap="#product-list">
<form action="/catalog" method="get">
<label>Filter <input name="q"></label>
</form>
<div id="product-list"><!-- replaced by server response --></div>
</sco-pe>
The server returns only the replacement fragment, not the full scope wrapper.
Keep expensive widgets
keep="same-html" preserves matching keyed custom elements when the server renders the same HTML again. This keeps client-side state for expensive widgets while still replacing them when their server-rendered HTML changes.
<sco-pe id="main" keep="same-html" keep-selector="admin-rich-select, admin-map">
<admin-rich-select id="department" name="department"></admin-rich-select>
</sco-pe>
If keep-selector is omitted, the default candidates are custom elements with stable ids. sco-pe compares the server HTML snapshot, not client-side mutations made after upgrade.
keep is intentionally an island-preservation feature, not a general-purpose DOM morphing API. Use stable, unique ids and keep the selector narrow. Regular server markup around the widget is still updated, including validation errors and reordered keyed islands.
Transitions
transition="fade" keeps a temporary outgoing layer during swaps:
<sco-pe id="main" transition="fade" transition-timeout="250"></sco-pe>
The generated outgoing layer:
.scope-outgoing
part="outgoing"
inert
aria-hidden="true"
You own the CSS animation. sco-pe only provides lifecycle hooks and removes the outgoing layer after transitionend, animationend, or the timeout.
The outgoing copy has its descendant ids removed to avoid duplicate document ids; transition-aware modules should therefore remain idempotent.
Transitions are cosmetic and do not keep the request busy. A newer swap cancels and removes any outgoing layer still playing.
For a small default busy indicator, applications can use:
sco-pe[busy]::after { content: "Loading…"; display: block; padding: .5rem; }
sco-pe[aria-busy="true"] { opacity: .65; cursor: progress; }
sco-pe[transitioning] .scope-outgoing-fade {
animation: scope-fade-out .2s ease both;
}
@keyframes scope-fade-out {
to { opacity: 0; }
}
Revalidation state
There is no cache/revalidation engine. The runtime only exposes state for explicit reloads:
document.querySelector("sco-pe#main").revalidate()
During the request the scope gets:
revalidating
.is-revalidating
aria-busy="true"
Renderable responses
sco-pe only renders HTML responses by default. Accepted content types are text/html and application/xhtml+xml. Non-HTML responses are refused before swapping, while Scope-Status and Scope-Alert headers may still be announced. Override renderableResponse(response) only for trusted, deliberate cases.
Error responses
HTML error responses, including 5xx responses, are swapped into the scope so the server can render a useful recovery page. Cancel that swap when the application needs a different policy:
document.addEventListener("scope:before-swap", (event) => {
if (event.detail.status >= 500) event.preventDefault();
});
Response headers
The server may declare response behavior with Scope-* headers:
Scope-Status: User saved
Scope-Alert: Please fix the highlighted fields
Scope-Title: Users
Scope-Location: /admin/login-form
Scope-Redirect: /admin/login
Scope-Reload: true
Scope-Script: /assets/admin/user-form.js
Scope-Style: /assets/admin/user-form.css
Scope-Select: sco-pe#main
Scope-Target: sidebar
Scope-Script loads external ES modules with dynamic import(). Modules are deduped by absolute URL. Inline scripts from fetched HTML are ignored by design.
Asset loading
Prefer headers when the controller knows what the response requires:
$response->headers->set('Scope-Script', '/assets/admin/rich-editor.js');
$response->headers->set('Scope-Style', '/assets/admin/rich-editor.css');
Custom elements can be registered up front:
customElements.whenDefined("sco-pe").then(() => {
customElements.get("sco-pe").configure({
components: {
"admin-datepicker": "/assets/admin/datepicker.js",
"admin-combobox": "/assets/admin/combobox.js",
},
});
});
When a fetched scope contains an undefined registered custom element, sco-pe imports the mapped module and waits for the element to be defined.
Asset declarations intentionally stay out of fetched markup. Use Scope-Script / Scope-Style when the server discovers dependencies at render time, or the component registry when the mapping is known by the application. A registered module must define its custom element during module evaluation; otherwise the load fails with a clear error instead of remaining busy indefinitely.
Fetched <script>, <style>, and stylesheet <link> elements are removed. Load executable or global styling dependencies through Scope-Script / Scope-Style. This is not an HTML sanitizer: responses are trusted same-origin application HTML, so inline event attributes and URLs remain the server application's responsibility.
Accessibility contract
sco-pe manages the scope lifecycle, not arbitrary announcements:
- sets
aria-busy="true"while the scope is loading; - sets
aria-busy="false"after the load finishes; - updates configured status/alert regions from
Scope-Status,Scope-Alert, or in-scoperole="status"/role="alert"; - focuses
[autofocus], the main heading, the error summary, or the first invalid field depending onfocusand response status; - keeps same-document hash links native and focuses the revealed target;
- allows
focus="none"andannounce="none"for silent/background updates.
Recommended base layout:
<div id="scope-status" role="status" aria-live="polite" aria-atomic="true"></div>
<div id="scope-alert" role="alert" aria-atomic="true"></div>
Recommended validation response:
<form action="/admin/users" method="post" aria-describedby="form-errors">
<div id="form-errors" role="alert" tabindex="-1">
Please fix the highlighted fields.
</div>
<label for="email">Email</label>
<input id="email" name="email" aria-invalid="true" aria-describedby="email-error">
<p id="email-error">Email is required.</p>
</form>
Lifecycle events
document.addEventListener("scope:before-load", (event) => {
// event.preventDefault() cancels the load.
});
document.addEventListener("scope:before-swap", (event) => {
// event.preventDefault() cancels the swap.
});
document.addEventListener("scope:after-swap", (event) => {});
document.addEventListener("scope:load", (event) => {});
document.addEventListener("scope:error", (event) => {});
document.addEventListener("scope:status", (event) => {});
document.addEventListener("scope:alert", (event) => {});
document.addEventListener("scope:transition-start", (event) => {});
document.addEventListener("scope:transition-end", (event) => {});
Configuration callbacks follow the same ownership rule:
afterLoad(scope, detail) runs for every scope whose lifecycle completes
onLoad(scope, detail) compatibility callback, once on the request-owning source scope
onError(scope, detail) runs for non-abort request errors on the source scope
scope:load details distinguish transport success from rendering:
ok true only for a successful HTTP response (304 also counts as unchanged success)
rendered true when HTML was actually swapped into a scope
status the HTTP status when a response was received
source id of the scope that owns the request
target id of the scope that owns the swap
A rendered 422 therefore reports { ok: false, rendered: true }.
When Scope-Target routes a response to another scope, the source owns the request, history, and active navigation. The target owns the swap, focus, announcement, scope:before-swap, and scope:after-swap. Both source and target receive scope:load, with source and target in the event detail.
stripHash normalizes /users/ and /users as the same URL for active-link and history comparisons.
History state stores one owning scope per browser entry. In a multi-scope admin layout, enable navigational history on the main content scope and update secondary scopes through Scope-Target.
Tests
npm install
npx playwright install chromium
npm test
The test suite covers initial src loading, link navigation, GET forms, 422 validation errors, live status/alert updates, focus management, same-document hash focus, scroll policies, non-HTML response refusal, native submitter overrides, external submit buttons, Scope-Script custom-element upgrades, autosubmit, keep, transitions, and revalidation state.
Demos
Run npm run serve and open /static/index.html for small working examples. See the demo guide for what each page demonstrates.