Live values
Solid 2UnstableA reading bar for the studio's story
Scroll through how the Lagoon glaze is made, and a bar along the top fills as you read, the minutes left beside it.
Two glazes, one kiln
Every Lagoon Vase starts as a plain stoneware body, thrown on the wheel and left to dry for a week under a loose sheet of plastic, so its walls dry evenly and nothing cracks at the rim.
It gets two glazes, not one. The first is a pale celadon that we dip; the second, a deep cobalt blue, is poured over it from the shoulder down while the vase turns slowly on a banding wheel.
Letting the heat decide
At 1,280 degrees both glazes melt. The cobalt is heavier and runs down through the celadon, carrying it with it in streaks, and where the two meet they mix into the greens the vase is named for.
We can choose how thick each coat is and where the pour starts, but not where the glazes go after that. The kiln decides. That's why no two vases are alike, and why we open the kiln slowly.
A firing takes eleven hours to climb and two days to cool. Opened too soon, the glaze crazes: fine cracks run through it as it cools faster than the clay beneath.
Choosing the ones that stay
Not every firing gives us vases we sell. A glaze that ran too far pools at the foot and fuses the vase to its shelf; one that ran too little stays a flat, even blue. We keep the ones in between.
Each of those is checked for pinholes, sanded at the foot, and filled with water overnight to be sure it holds. Then it's photographed on the same white sheet as every other piece in the shop.
Looking after one
The glaze is glass, so it's watertight and wipes clean. Wash it by hand in warm water; a dishwasher's detergent dulls the surface over the years.
If you want to see the runs at their best, put it where light comes from the side: the thicker streaks catch it, and the thin ones let the pale glaze through.
Reduced motion: Nothing changes: the bar is where the scroll is, not an animation of its own.
Why
A long read is easier to start when you can see how long it is, and easier to finish when you can see how far you've come. A bar that fills as you scroll says both at a glance, and the minutes left say it in words.
createScroll gives the panel's scroll as live values: how far it's scrolled, and the progress through its range, from 0 to 1, set within the scroll's own event. The bar takes the progress as its scaleX, drawn from its left edge, and follows the scroll frame by frame. createTransform maps the same progress to the minutes left. Leave out container, and it's the page's scroll.
The code
Motion's useScroll and useTransform are createScroll and createTransform, made in the component's body, and the panel is a ref. Motion renders a motion value as text, <motion.span>{label}</motion.span>, so React doesn't render the component again as the minutes change; Solid reads them in the markup, and only that text changes.
import { LAGOON_ARTICLE } from "@wardic/example-storefront-shared/stories"
import { createScroll, createTransform, wardic } from "@wardic/solid/unstable/v2"
import { For } from "solid-js"
export default function ReadingProgress() {
let panel!: HTMLDivElement
// How far the article's panel is scrolled, from 0 to 1. Without `container`, it's the page's.
const { scrollYProgress } = createScroll({ container: () => panel })
// The minutes left, read from the same progress.
const minutesLeft = createTransform(scrollYProgress, [0, 1], [LAGOON_ARTICLE.minutes, 0])
return (
<div class="reading">
<div class="reading-head">
<strong>{LAGOON_ARTICLE.title}</strong>
<span class="muted" aria-live="polite">
{Math.ceil(minutesLeft())} min left
</span>
</div>
{/* The bar is the scroll itself, scaled across from its left edge: no animation of its own. */}
<wardic.div class="reading-bar" style={{ scaleX: scrollYProgress }} />
<div class="reading-panel" ref={panel} tabindex="0" aria-label={LAGOON_ARTICLE.title}>
<For each={LAGOON_ARTICLE.sections}>
{(section) => (
<section>
<h3>{section.heading}</h3>
<For each={section.paragraphs}>{(paragraph) => <p>{paragraph}</p>}</For>
</section>
)}
</For>
</div>
</div>
)
}
"use client"
import { LAGOON_ARTICLE } from "@wardic/example-storefront-shared/stories"
import { motion, useScroll, useTransform } from "motion/react"
import { useRef } from "react"
export default function ReadingProgress() {
const panel = useRef<HTMLDivElement>(null)
// How far the article's panel is scrolled, from 0 to 1. Without `container`, it's the page's.
const { scrollYProgress } = useScroll({ container: panel })
// The minutes left, read from the same progress: a motion value rendered as text, so React
// doesn't render again as it changes.
const minutesLeft = useTransform(scrollYProgress, [0, 1], [LAGOON_ARTICLE.minutes, 0])
const label = useTransform(minutesLeft, (minutes) => `${Math.ceil(minutes)} min left`)
return (
<div className="reading">
<div className="reading-head">
<strong>{LAGOON_ARTICLE.title}</strong>
<motion.span className="muted" aria-live="polite">
{label}
</motion.span>
</div>
{/* The bar is the scroll itself, scaled across from its left edge: no animation of its own. */}
<motion.div className="reading-bar" style={{ scaleX: scrollYProgress }} />
<div className="reading-panel" ref={panel} tabIndex={0} aria-label={LAGOON_ARTICLE.title}>
{LAGOON_ARTICLE.sections.map((section) => (
<section key={section.heading}>
<h3>{section.heading}</h3>
{section.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</section>
))}
</div>
</div>
)
}