Shipping less

A w.* element animates its values with nothing else: initial, animate, exit, variants and transitions. Each thing it does beyond that, a gesture, layout or a colour space, is a Feature: code a page ships only if it asks for it.

Live demo
shipping/solid2.tsx
import { LazyWardic, w } from "@wardic/solid/unstable/v2"
import { createSignal, Show } from "solid-js"

/** A ball that drags once you ask: the page fetches `drag` on the press, not with itself. */
export default function Shipping() {
  const [wanted, setWanted] = createSignal(false)
  // How long `drag` took to arrive, from the press: absent until it has.
  const [arrivedIn, setArrivedIn] = createSignal<number>()
  let arena!: HTMLDivElement
  let pressedAt = 0

  // The section's Features, as a function: it runs as the section renders, so on the press.
  const loadDrag = async () => {
    const { drag } = await import("./drag-feature.ts")
    setArrivedIn(Math.round(performance.now() - pressedAt))
    return drag
  }

  const load = () => {
    pressedAt = performance.now()
    setWanted(true)
  }

  return (
    <div class="sample-stack">
      <button class="sample-button" disabled={wanted()} onClick={load}>
        Load drag
      </button>
      <div class="sample-arena" ref={arena}>
        <Show when={wanted()} fallback={<div class="sample-ball" />}>
          <LazyWardic features={loadDrag}>
            <w.div class="sample-ball" drag dragConstraints={() => arena} whileDrag={{ scale: 1.15 }} />
          </LazyWardic>
        </Show>
      </div>
      <p class="sample-note" aria-live="polite">
        <Show when={wanted()} fallback={<>No drag yet: the ball stays put.</>}>
          <Show when={arrivedIn() !== undefined} fallback={<>Loading drag…</>}>
            Drag arrived in {arrivedIn()} ms.
          </Show>
        </Show>
      </p>
    </div>
  )
}
shipping/solid1.tsx
import { LazyWardic, w } from "@wardic/solid"
import { createSignal, Show } from "solid-js"

/** A ball that drags once you ask: the page fetches `drag` on the press, not with itself. */
export default function Shipping() {
  const [wanted, setWanted] = createSignal(false)
  // How long `drag` took to arrive, from the press: absent until it has.
  const [arrivedIn, setArrivedIn] = createSignal<number>()
  let arena!: HTMLDivElement
  let pressedAt = 0

  // The section's Features, as a function: it runs as the section renders, so on the press.
  const loadDrag = async () => {
    const { drag } = await import("./drag-feature-solid1.ts")
    setArrivedIn(Math.round(performance.now() - pressedAt))
    return drag
  }

  const load = () => {
    pressedAt = performance.now()
    setWanted(true)
  }

  return (
    <div class="sample-stack">
      <button class="sample-button" disabled={wanted()} onClick={load}>
        Load drag
      </button>
      <div class="sample-arena" ref={arena}>
        <Show when={wanted()} fallback={<div class="sample-ball" />}>
          <LazyWardic features={loadDrag}>
            <w.div class="sample-ball" drag dragConstraints={() => arena} whileDrag={{ scale: 1.15 }} />
          </LazyWardic>
        </Show>
      </div>
      <p class="sample-note" aria-live="polite">
        <Show when={wanted()} fallback={<>No drag yet: the ball stays put.</>}>
          <Show when={arrivedIn() !== undefined} fallback={<>Loading drag…</>}>
            Drag arrived in {arrivedIn()} ms.
          </Show>
        </Show>
      </p>
    </div>
  )
}
shipping/motion.tsx
import { LazyMotion } from "motion/react"
import * as m from "motion/react-m"
import { useRef, useState } from "react"

/** A ball that drags once you ask: the page fetches Motion's Features on the press, not with itself. */
export default function Shipping() {
  const [wanted, setWanted] = useState(false)
  // How long the Features took to arrive, from the press: absent until they have.
  const [arrivedIn, setArrivedIn] = useState<number>()
  const arena = useRef<HTMLDivElement>(null)
  const pressedAt = useRef(0)

  // Motion's drag comes in `domMax`, with layout: they load for the whole page.
  const loadFeatures = async () => {
    const features = (await import("./motion-features.ts")).default
    setArrivedIn(Math.round(performance.now() - pressedAt.current))
    return features
  }

  const load = () => {
    pressedAt.current = performance.now()
    setWanted(true)
  }

  let status = "No drag yet: the ball stays put."
  if (wanted) status = "Loading drag…"
  if (arrivedIn !== undefined) status = `Drag arrived in ${arrivedIn} ms.`

  return (
    <div className="sample-stack">
      <button className="sample-button" disabled={wanted} onClick={load}>
        Load drag
      </button>
      <div className="sample-arena" ref={arena}>
        {wanted ? (
          <LazyMotion features={loadFeatures}>
            <m.div className="sample-ball" drag dragConstraints={arena} whileDrag={{ scale: 1.15 }} />
          </LazyMotion>
        ) : (
          <div className="sample-ball" />
        )}
      </div>
      <p className="sample-note" aria-live="polite">
        {status}
      </p>
    </div>
  )
}

The Features

FeatureWhat it brings
hoverwhileHover, onHoverStart and onHoverEnd
presswhileTap, onTap, onTapStart and onTapCancel, from the pointer and the keyboard
focuswhileFocus
inViewwhileInView, viewport, onViewportEnter and onViewportLeave
panonPan, onPanStart, onPanSessionStart and onPanEnd
dragdrag, its constraints, the glide on release, and dragControls
layoutlayout, layoutId, layoutScroll and layoutRoot
colorsoklch(), oklab(), lab(), lch(), hwb() and color(). Hex, rgb(), hsl() and colour names need nothing.

On Solid 2, the gesture Features bring the ward's events too, such as "hover" and "tap". Motion's two sets are here under the same names: domAnimation is hover, press, focus and inView, and domMax is every Feature. Each is a list, so features={[domAnimation, layout]} works.

Where they go

  • <WardicProvider features> gives them to the whole app: usually domAnimation.
  • <LazyWardic features> gives them to a section. Its elements have the provider's Features and the section's, and a sibling section's Features stay its own. A page that drags wraps what drags in <LazyWardic features={[drag]}>, so drag ships with that page's code, and pages that don't drag never load it.
  • Components that need a Feature bring it: Reorder.Item brings drag, layout and the gestures, and Reorder.Group brings layout. LayoutGroup brings layout for its own layoutDependency only: a layout element under it still needs layout from its section.

Loaded when it's wanted

features can be a function that loads them, as in the demo:

// drag-feature.ts holds `export { drag } from "@wardic/solid/unstable/v2"`, so the import is a chunk of its own.
<LazyWardic features={() => import("./drag-feature.ts").then((m) => m.drag)}>…</LazyWardic>

It loads as the section renders, in the browser only. So a section rendered on a press loads on the press, one rendered as it scrolls into view loads then, and one rendered once the page has mounted loads after the first paint. While it loads:

  • Values animate at once, and each Feature takes effect as it arrives. A drag tried before drag has arrived does nothing, and isn't replayed.
  • An element with a layout prop starts laying out when layout arrives: it animates its next layout change.
  • The server loads nothing: it renders each element at its starting style, as it always does.

A Feature left out

A prop whose Feature isn't there does nothing: whileHover without hover doesn't play, drag without drag doesn't drag, and an oklch() colour without colors isn't read as a colour. In development, Wardic warns once for each Feature, naming it and where to add it.

Every Feature at once

wardic.* is the element with every Feature, whatever its section has, and so are wardic.create and the ward that isn't w's: createWard on Solid 1, ward() on Solid 2. It's handy for trying something out, and heavy for a page. strict on a WardicProvider or a LazyWardic makes one throw in development, so none reaches a page by accident.

What it saves

Measured on Solid 1's binding at 1.0.0-rc.2, gzipped, each over the Slim element alone, 122 KB for the whole app:

FeatureSize
hover5 KB, the input every gesture but inView stands on included
press, focus and inView, after hover2 KB
drag, after domAnimation8 KB
layout23 KB
colors5 KB
Every Feature, a wardic.* element42 KB