Platform recipes: UI patterns you build on the platform, no component needed

These are the everyday widgets a UI kit would ship - date pickers, progress rings, trees, context menus, a docs sidebar - built from the web platform itself. Each one is a working demo with its complete, copyable source in a View source panel beneath it. Reach for these when you are wondering how do I do X in JST: for everything here the answer is that you write the HTML and let the platform do the work.

green - the platform already does this, no JST needed red - a JST component; the template you see is the implementation purple - JST's CSS-only layout hooks third-party - an external library dropped into a JST component

The whole page is hermetic: every image is an inline SVG data URI and nothing is fetched over the network. Serve the repo root and open this file to try each demo.

Date, time and number inputs

HTML + CSS

A date, time and quantity form with no component. Native input[type=date], input[type=time] and input[type=number] give you a picker, keyboard entry and locale-aware formatting in every browser, and the classless base already styles them with the theme variables.

View source
<jst-field><label>Date</label>
  <input type="date" value="2026-07-10">
</jst-field>
<jst-field><label>Time</label>
  <input type="time" value="09:30">
</jst-field>
<jst-field><label>Quantity</label>
  <input type="number" min="0" max="99" value="3">
</jst-field>

<!-- No CSS. jst-layout.css styles native inputs by element. -->

File input

HTML + CSS

A styled file picker with no component: the native input[type=file] handles selection, and the classless base skins its button through ::file-selector-button.

View source
<jst-field>
  <label>Attachment</label>
  <input type="file">
</jst-field>

/* Already in jst-layout.css - shown for reference: */
::file-selector-button {
  font: inherit;
  padding: var(--jst-space-2xs) var(--jst-space-s);
  border: var(--jst-border-width) solid var(--jst-border);
  border-radius: var(--jst-radius);
  background: var(--jst-surface);
  color: var(--jst-fg);
  cursor: pointer;
}

Video

HTML + CSS

Native <video> gives you play controls, fullscreen, picture-in-picture, a poster and <track> captions with no library. (This demo has a poster but no media source, so it shows the frame without playing.)

View source
<video controls width="320" poster="poster.jpg">
  <source src="clip.mp4" type="video/mp4">
  <track kind="captions" default srclang="en" label="English"
         src="captions.vtt">
  Your browser cannot play this video.
</video>

<!-- controls, fullscreen, PiP and captions are all native. -->

Progress ring and bar

HTML + CSS + JS

A percentage indicator as a ring and a bar. The ring is a conic-gradient() driven by one --value custom property; the linear case is just <progress>. Drag the slider to drive both.

View source
<div class="ring-demo" style="--value: 68">
  <div class="ring"><span class="ring-num">68%</span></div>
  <input type="range" min="0" max="100" value="68"
    oninput="const d = this.closest('.ring-demo');
             d.style.setProperty('--value', this.value);
             d.querySelector('.ring-num').textContent = this.value + '%'">
  <progress value="0.68" max="1"></progress>
</div>

.ring {
  inline-size: 5.5rem; aspect-ratio: 1; border-radius: 50%;
  display: grid; place-items: center;
  background: conic-gradient(var(--jst-accent)
                calc(var(--value) * 1%), var(--jst-border) 0);
  mask: radial-gradient(farthest-side, transparent 58%, #000 59%);
}

Stepper / steps

HTML + CSS

A multi-step progress header (checkout, onboarding) built from an ordered list: CSS counters number the steps and aria-current="step" marks the one you are on.

  1. Cart
  2. Shipping
  3. Payment
  4. Review
View source
<ol class="steps">
  <li>Cart</li>
  <li aria-current="step">Shipping</li>
  <li>Payment</li>
  <li>Review</li>
</ol>

.steps { counter-reset: step; display: flex; list-style: none; }
.steps li { counter-increment: step; flex: 1; text-align: center; }
.steps li::before { content: counter(step); /* numbered bubble */ }
.steps li[aria-current="step"]::before {
  background: var(--jst-accent); color: var(--jst-accent-fg);
}

Tree view

HTML + CSS

A collapsible tree (file explorer, nav) from nested <details>/<summary> with connector lines. Expand, collapse and keyboard focus all come from the platform.

src
components
button.js
card.js
index.js
tests
smoke.mjs
View source
<div class="tree">
  <details open>
    <summary>src</summary>
    <details>
      <summary>components</summary>
      <div class="leaf">button.js</div>
    </details>
    <div class="leaf">index.js</div>
  </details>
</div>

.tree details { border-inline-start: 1px solid var(--jst-border);
                padding-inline-start: var(--jst-space-s); }

Floating label

CSS only

A label that lifts out of the field on focus or input, in pure CSS with :placeholder-shown and :focus-within - no JavaScript at all.

View source
<label class="float">
  <input type="text" placeholder=" ">
  <span>Email address</span>
</label>

.float { position: relative; }
.float span { position: absolute; inset-block-start: 0.85rem; transition: 0.15s; }
.float input:focus + span,
.float input:not(:placeholder-shown) + span {
  transform: translateY(-0.75rem) scale(0.8);
}

One-time code

HTML + CSS

A verification-code field. One input with autocomplete="one-time-code" and inputmode="numeric"; on a phone the platform offers the SMS code for one-tap autofill.

View source
<label>Verification code</label>
<input class="otp" type="text" inputmode="numeric"
       autocomplete="one-time-code" maxlength="6"
       pattern="[0-9]*" placeholder="000000">

.otp { font-family: var(--jst-font-mono); letter-spacing: 0.6em;
       text-align: center; }

<!-- iOS/Android surface the SMS code above the keyboard. -->

Context menu

HTML + CSS

An actions menu with no JavaScript: a [popover] opened by a command / commandfor invoker button, anchored with CSS anchor positioning. Light dismiss (outside click, Esc) is built in.

View source
<button command="toggle-popover" commandfor="menu"
        style="anchor-name: --btn">Actions ▾</button>

<div popover id="menu" class="ctx-menu"
     style="position-anchor: --btn; position-area: bottom span-right">
  <button command="hide-popover" commandfor="menu">Rename</button>
  <button command="hide-popover" commandfor="menu">Duplicate</button>
</div>

<!-- No JS: the invoker opens it, popover light-dismiss closes it. -->

Enter / exit animation

CSS only

Fade-and-lift a panel in on open and reverse it on close, in CSS alone. @starting-style plus transition-behavior: allow-discrete animate a popover both ways, including the switch off display: none. No library.

Animated both ways.

Fades and lifts in on open, and reverses on close because display and overlay transition with allow-discrete.

View source
<button command="toggle-popover" commandfor="pop">Toggle</button>
<div popover id="pop" class="anim-pop">Animated both ways.</div>

.anim-pop {
  opacity: 0; transform: translateY(0.5rem) scale(0.97);
  transition: opacity 0.25s, transform 0.25s,
              overlay 0.25s allow-discrete,
              display 0.25s allow-discrete;
}
.anim-pop:popover-open { opacity: 1; transform: none; }
@starting-style {
  .anim-pop:popover-open { opacity: 0; transform: translateY(0.5rem); }
}

Comparison slider (before / after)

HTML + CSS + JS

A drag-to-reveal before/after image wipe. An input[type=range] writes a --pos custom property; the top image is clipped with clip-path: inset(). The one-line oninput is a native inline handler. Both images are inline SVG.

After Before
View source
<div class="compare" style="--pos: 50%">
  <img class="under" src="after.jpg" alt="After">
  <img class="over"  src="before.jpg" alt="Before">
  <div class="handle"></div>
  <input type="range" min="0" max="100" value="50"
    oninput="this.closest('.compare')
      .style.setProperty('--pos', this.value + '%')">
</div>

.compare { position: relative; --pos: 50%; }
.compare img { position: absolute; inset: 0; object-fit: cover; }
.compare .over { clip-path: inset(0 calc(100% - var(--pos)) 0 0); }
.compare .handle { inset-inline-start: var(--pos); }

Split panel

HTML + CSS

Two resizable panes side by side, using native CSS resize on one pane inside a flex row. Drag the handle at the pane's bottom-inline corner.

Left pane. Drag my corner handle to resize.
Right pane takes the remaining space.

Limits: resize gives a corner grip only (no full-height divider), one axis at a time, and clamps with min/max-inline-size. When you need a draggable bar between two panes, that is a real component - reach for a library or write one.

View source
<div class="split">
  <div class="pane pane-a">Left</div>
  <div class="pane pane-b">Right</div>
</div>

.split { display: flex; gap: var(--jst-space-2xs); }
.split .pane-a {
  resize: horizontal; overflow: auto;
  inline-size: 45%; min-inline-size: 5rem; max-inline-size: 85%;
}
.split .pane-b { flex: 1; }

Scrollspy

<scroll-spy>

A table of contents that highlights the section you are currently reading - the classic docs scrollspy, and the one recipe here that is a JST component. Its onreveal synthetic event fires as each section scrolls into view; the handler records the active id and JST re-highlights the list. For the same idea as a nested sidebar with an active bar, see the docs sidebar below.

View source
<script type="jst" name="scroll-spy" attributes="sections active">
  <nav class="spy-toc">
    <ol>
      $ (sections || []).forEach(s => {
        <li><a href="#$(s.id)"
             aria-current="$(active === s.id ? 'true' : 'false')">
          $(s.label)</a></li>
      $ })
    </ol>
  </nav>
  $ (sections || []).forEach(s => {
    <section id="$(s.id)" jst-key="$(s.id)"
             onreveal="if (el.active !== this.id) el.active = this.id">
      <h4>$(s.label)</h4><p>$(s.body)</p>
    </section>
  $ })
</script>

<scroll-spy sections='[...]' active="spy-intro"></scroll-spy>

Docs sidebar navigation

HTML + CSS + JS

A nested documentation sidebar - section headings with indented items, light indent guides, and a coloured bar on the active item - kept in sync with the content pane beside it. It is the scrollspy idea applied to a real docs layout: an IntersectionObserver whose root is the scroll pane watches the sections and moves the active marker. Scroll the pane on the right.

Install

Copy the JST files into your app, or load them from a pinned CDN tag. There is no build step and nothing to configure to get the first component rendering on the page.

Configure

Override --jst-* variables at :root to theme, and call configure({ dev: true }) while developing so render errors surface as visible boxes instead of stale DOM.

Layout

Compose pages from the layout primitives - jst-stack, jst-grid, jst-sidebar - styled by element with zero JavaScript required.

Theming

One accent variable drives a derived ramp, and [data-scheme] flips light and dark. Skins override only the theme variables, so the same markup re-skins to another framework's look.

View source
<div class="sidenav">
  <nav class="sidenav-rail" aria-label="Documentation">
    <ul>
      <li class="sidenav-section">
        <span class="sidenav-heading">Getting started</span>
        <ul>
          <li><a href="#sn-install">Install</a></li>
          <li><a href="#sn-configure">Configure</a></li>
        </ul>
      </li>
    </ul>
  </nav>
  <div class="sidenav-content" tabindex="0">
    <section id="sn-install"><h4>Install</h4><p>…</p></section>
    <section id="sn-configure">…</section>
  </div>
</div>

/* light indent guide + active bar, like a docs sidebar */
.sidenav-section > ul { border-inline-start: 1px solid var(--jst-border); }
.sidenav-section > ul a { border-inline-start: 2px solid transparent; }
.sidenav-section > ul a[aria-current="true"] {
  border-inline-start-color: var(--jst-accent);
  color: var(--jst-accent-700, var(--jst-accent)); font-weight: 700;
}

<script>
// Same IntersectionObserver idea as the scrollspy recipe, but the root is the
// scrollable content pane and the marker is the matching sidebar link.
const pane = document.querySelector('.sidenav-content');
const links = new Map([...document.querySelectorAll('.sidenav-rail a')]
  .map(a => [a.hash.slice(1), a]));
const visible = new Set();
const setActive = id => links.forEach((a, key) =>
  a.setAttribute('aria-current', key === id ? 'true' : 'false'));
const io = new IntersectionObserver(entries => {
  for (const e of entries) e.isIntersecting
    ? visible.add(e.target.id) : visible.delete(e.target.id);
  // highlight the topmost section currently in view
  let top = Infinity, active = null;
  visible.forEach(id => {
    const y = pane.querySelector('#' + id).getBoundingClientRect().top;
    if (y < top) { top = y; active = id; }
  });
  if (active) setActive(active);
}, { root: pane, threshold: 0.1 });
pane.querySelectorAll('section[id]').forEach(s => io.observe(s));
setActive(pane.querySelector('section[id]').id);   // initial
</script>

Icons

HTML + CSS

Themeable, text-aligned icons from an inline SVG sprite (<symbol> / <use>) sized by the jst-icon primitive. Any icon set drops straight in: Lucide, Heroicons, Font Awesome Free - copy their paths into symbols, no runtime library.

Home Starred Alerts
View source
<!-- Define the sprite once (paste any icon set's paths here) -->
<svg width="0" height="0" aria-hidden="true">
  <symbol id="ic-home" viewBox="0 0 24 24" ...>
    <path d="M3 10.5 12 3l9 7.5"/> ...
  </symbol>
</svg>

<!-- Use it anywhere, sized by the jst-icon primitive. The viewBox on the
     referencing svg matters: no viewBox means no aspect ratio, and Safari
     collapses the auto width to zero. -->
<jst-icon><svg viewBox="0 0 24 24"><use href="#ic-home"/></svg> Home</jst-icon>

/* jst-icon (in jst-layout.css) aligns and sizes the svg to the text. */

Takeaway

Almost everything here is plain HTML and CSS with no runtime; a couple add a few lines of vanilla JavaScript, and exactly one - the scrollspy - is a small JST component that turns a platform observation into rendered state. That is the pattern worth taking away: reach for the platform first, keep the markup in your hands, and add JST only where it earns a place.