Primitives Layout and display
Findable
Text shortened on screen and whole for the browser's find-in-page. The short form is drawn from the data-slean-findable attribute; the whole value sits under hidden="until-found", which find searches and reveals. One shared beforematch listener draws the found value over the short form, keeps one open at a time and folds it back when find moves on. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1657 B brotli · 1840 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
hidden="until-found",beforematch,content: attr(),translate- Shared listeners
- beforematch
- Per-instance listeners
- none
- Lazy state
- none
- Native base
hidden="until-found" + beforematch
On this page
Example
| Customer | Payment | Amount |
|---|---|---|
| Amara Okafor | pay_7f3a9c21e84b40d6a1c5 | €120.00 |
| Jonas Lindqvist | pay_2b8e0d47c19a4f35b7e2 | €48.50 |
| Mei Tanaka | pay_c5d1f8a03e6b492d8f10 | €310.25 |
<!-- The short form is the attribute; the whole value is the only text. No whitespace
between the root and its part: the root's only rendered text is the short form. -->
<span data-slean-findable="…4d4d38"
><span data-slean-part="full" hidden="until-found">tx-0123456789abcdef4d4d38</span></span
><script lang="ts">
// With @svelte-lean/vite the register import is injected for every data-slean-findable
// attribute, static or dynamic.
import '@svelte-lean/styles/findable.css';
let { id, tail = 6 }: { id: string; tail?: number } = $props();
</script>
<span data-slean-findable="…{id.slice(-tail)}"
><span data-slean-part="full" hidden="until-found">{id}</span></span
>Why this implementation exists
An operator table shows an opaque id by its last characters so the column stays narrow, but the operator searches the page for the whole id they were sent. A title attribute or a hint is not searched by find; a visually hidden copy is, but then the id is found twice and screen readers read both.
The platform has the answer: hidden="until-found" content is searched by find-in-page, and on a match the browser fires beforematch and reveals it. What it does not do is draw the revealed value where the short form was without moving the row, keep it inside a scrolling table, or fold it back when the operator moves on. That is what the behavior adds, through one beforematch handler registered with the shared router; the listeners that notice the end of a find exist only while a value is open (tests/findable.test.ts keeps one shared listener for a thousand values and adds none while idle).
The browser owns
- find-in-page: searching the whole value under hidden="until-found", revealing it on a match, the highlight and the scroll to it
- the same reveal for a fragment link to an element inside the value
- the short form: CSS generated content, which find does not search, so the value is counted once
Svelte Lean owns
- one shared beforematch listener for every findable value on the page; no state per element
- one value open at a time: the next match folds the previous one back under hidden="until-found"
- folding when find moves to another hidden match, on Escape, and when the find bar closes (the page takes its focus back with no press)
- the shift that keeps the open value inside a scroller that would clip it, measured on the two frames after a match
- slean:findend with the value and the reason; findable.css: the short form and the open value drawn over it
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every data-slean-findable attribute in the markup, with a static or a dynamic
value; without it, import the
register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/findable/register';import '@svelte-lean/styles/findable.css';Put the short form in data-slean-findable and the whole value in a direct child <span data-slean-part="full" hidden="until-found">. Keep whitespace out between the two: the root’s only rendered text is the short form, drawn by findable.css, which is required.
Listen for slean:findend to close whatever you opened for the match; its reason is moved, escape, closed or code.
<script lang="ts">
import type { FindEndDetail } from '@svelte-lean/primitives/findable';
let table: HTMLElement;
$effect(() => {
// slean:findend bubbles from the root once its value folded back: find moved on
// ('moved'), Escape ('escape'), the find bar closed ('closed') or foldFound() ('code').
const onfindend = (event: Event) => {
const { value, reason } = (event as CustomEvent<FindEndDetail>).detail;
console.log(`${value} folded: ${reason}`);
};
table.addEventListener('slean:findend', onfindend);
return () => table.removeEventListener('slean:findend', onfindend);
});
</script>
<table bind:this={table}>…</table>Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | any element with data-slean-findable="…" | – | yes | The attribute value is the short form the stylesheet draws. Carries data-slean-found while its value is open. Copy and hint can sit on the same element. |
| full | <span hidden="until-found"> | full | yes | The whole value, text only, as the root’s direct child. Out of the flow; drawn over the short form while open. |
Runtime profile
The findable registers one beforematch handler with the shared router. On a match it marks the root data-slean-found, folds any other open value and starts a session: a capturing beforematch, pointerdown and keydown listener on the document and focus and blur on the window, removed together when the value folds. It reads layout on the two frames after a match (the computed overflow of the ancestors, the box of each one that clips, the value's box) to shift the value with an inline translate; nothing is read while idle or while the value stays open. The browser does not say when its find bar closes; a return of the page's focus with no press on the page is read as that.
Accessibility contract
- Folded, assistive technology reads the short form: CSS generated content is part of the element’s text. The whole value is not rendered, so it is not in the accessibility tree; open, it is.
aria-labelis prohibited on a generic element, so the behavior adds none. Where the whole value must reach assistive technology without find, give it where a name is allowed:aria-labelon a copy<button>, as in the example below, or a hint with the whole value on a focusable root.- The behavior never moves focus. Escape on the page folds the open value without being consumed.
Keyboard
| Key | When | Result |
|---|---|---|
| Ctrl+F/Cmd+F | the browser’s find bar | Find reaches the whole value, reveals it and scrolls to it |
| Escape | on the page | Folds the open value |
| Escape | in the find bar | Closes the find bar; the page takes its focus back and the value folds |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
hidden="until-found" and beforematch | Limited availability: Chrome and Edge 102, Firefox 139; not in Safari as far as we know | The value stays hidden (display: none); the short form shows and find does not reach the whole value |
content: attr() on ::after | Baseline widely available | Not applicable |
The translate property | Baseline widely available since 2022 | The open value is not shifted inside a clipping scroller |
Without JavaScript
Find still searches and reveals the whole value, and the stylesheet draws it over the short form; it stays open until the page reloads and no slean:findend is dispatched.
Server rendering
Render the markup as it is, including hidden="until-found". Nothing runs on the server or at import.
Before hydration
Before the behavior loads, find reveals a value and the stylesheet draws it, unshifted, and it stays open. The registration attaches no per-element state, so the first match after hydration is handled.
Styling
findable.css draws the short form (::after with content: attr(data-slean-findable)), takes the whole value out of the flow and, once it is open, draws it over the short form with the warning colors, a border and a shadow, starting where the short form starts so the row keeps its height. It never sets display on the value, so where hidden="until-found" is unknown the user agent's [hidden] rule keeps it hidden. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | margin-inline, padding-inline |
--slean-warning | oklch(76% 0.16 80) | border |
--slean-radius-sm | 0.375rem | border-radius |
--slean-warning-soft | oklch(96% 0.05 85) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-shadow-md | 0 4px 12px oklch(0% 0 0 / 0.1), 0 1px 3px oklch(0% 0 0 / 0.08) | box-shadow |
State selectors the stylesheet targets, all from the platform or ARIA: [hidden].
Variant attributes: data-slean-findable.
Controlled integration
foldFound() folds the open value from code and dispatches slean:findend with reason code; foundRoot() returns the root whose value is open; supportsFindReveal() tells whether this browser searches and reveals hidden="until-found". shiftInside(box, bounds) is the pure shift the behavior applies.
import { foldFound, foundRoot, supportsFindReveal } from '@svelte-lean/primitives/findable';
// Whether find-in-page can reach the whole values on this browser.
const findable = supportsFindReveal();
// Before a view is replaced: fold the value find opened (dispatches slean:findend, reason 'code').
if (foundRoot()) foldFound();Compatibility notes
hidden="until-found" and beforematch are supported in Chrome and Edge 102 and Firefox 139, and not in Safari as far as we know; check the current status before relying on it. Where they are missing, the whole value is hidden like any hidden element, the short form shows, find does not reach the whole value and nothing runs. A fragment link to an element inside the value reveals it the same way. There is no find of its own and no fallback copy: a visually hidden copy would be found twice.
Examples
With copy and hint
The three attribute primitives on one button: find reaches the whole refund id, a click copies
it, the hint shows it whole, and the button's name carries it for assistive technology. Search
the page for 91e4b7a2, then click the opened id.
Refund
<!-- Findable, copy and hint on one element: find reaches the whole id, a click copies it,
the hint shows it whole and the button's name carries it for assistive technology. -->
<button type="button" class="id"
data-slean-findable="…{id.slice(-6)}"
data-slean-copy={id}
data-slean-hint={id} data-slean-hint-mono data-slean-hint-copied="Copied"
aria-label="Copy transaction {id}"
><span data-slean-part="full" hidden="until-found">{id}</span></button
>Cells that clip
The open value is shifted sideways to stay inside a scroller that would clip it, but it cannot
be wider than the scroller. A cell that clips its own text for an ellipsis lets the value out
while the root carries data-slean-found:
/* A cell that clips its text for an ellipsis lets the found value out while it is open. */
td:has(> [data-slean-found]) {
overflow: visible;
}Testing
packages/primitives/tests/findable.test.tsVitest: opening, one at a time, Escape, the find bar closing against a click, nested values, the shift, copy and hint on one element, 1000 values and no listener while idleapps/playground/tests/primitives/findable.spec.tsPlaywright: the real reveal through a fragment navigation, the row height, the shift inside a scroller, one open at a time, Escape, the find bar closing, copy and hint on one element, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/findable/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/findable/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/findable/behavior.tsthe behavior definition, foldFound(), shiftInside() and supportsFindReveal()packages/primitives/src/findable/register.tsthe registration modulepackages/primitives/src/findable/validate.tsdevelopment validation messagesdocs/adr/0009-attribute-behaviors.mdwhy an attribute on any element can be the root of a behaviorpackages/styles/css/findable.cssthe optional stylesheet