// @ts-nocheck import { createEffect, on, onMount, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { TextShimmer } from "./text-shimmer" import { TextReveal } from "./text-reveal" export default { title: "UI/ThinkingHeading", id: "components-thinking-heading", tags: ["autodocs"], parameters: { docs: { description: { component: `### Overview Playground for animating the secondary heading beside "Thinking". Uses TextReveal for the production heading animation with tunable duration, travel, bounce, and fade controls.`, }, }, }, } const HEADINGS = [ "Planning key generation details", "Analyzing error handling", undefined, "Reviewing authentication flow", "Considering edge cases", "Evaluating performance", "Structuring the response", "Checking type safety", "Designing the API surface", "Mapping dependencies", "Outlining test strategy", ] // --------------------------------------------------------------------------- // CSS // // Custom properties driven by sliders: // --h-duration transition duration (e.g. "600ms") // --h-duration-raw unitless number for calc (e.g. "600") // --h-blur blur radius (e.g. "4px") // --h-travel vertical travel distance (e.g. "18px") // --h-spring full cubic-bezier for movement (set from bounce slider) // --h-spring-soft softer version for width transitions // --h-mask-size fade depth at top/bottom of odometer mask // --h-mask-pad base padding-block on odometer track // --h-mask-height extra vertical mask area per side // --h-mask-bg background color for fade overlays // --------------------------------------------------------------------------- const STYLES = ` /* ── shared base ────────────────────────────────────────────────── */ [data-variant] { display: inline-flex; align-items: center; } [data-variant] [data-slot="track"] { display: grid; overflow: visible; min-height: 20px; justify-items: start; align-items: center; transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1)); } [data-variant] [data-slot="entering"], [data-variant] [data-slot="leaving"] { grid-area: 1 / 1; line-height: 20px; white-space: nowrap; justify-self: start; } /* kill transitions before fonts are ready */ [data-variant][data-ready="false"] [data-slot="track"], [data-variant][data-ready="false"] [data-slot="entering"], [data-variant][data-ready="false"] [data-slot="leaving"] { transition-duration: 0ms !important; } /* ── 1. spring-up ───────────────────────────────────────────────── * * New text rises from below, old text exits upward. */ [data-variant="spring-up"] [data-slot="entering"], [data-variant="spring-up"] [data-slot="leaving"] { transition-property: transform, opacity, filter; transition-duration: var(--h-duration, 600ms), calc(var(--h-duration-raw, 600) * 0.6 * 1ms), calc(var(--h-duration-raw, 600) * 0.5 * 1ms); transition-timing-function: var(--h-spring), ease-out, ease-out; } [data-variant="spring-up"] [data-slot="entering"] { transform: translateY(0); opacity: 1; filter: blur(0); } [data-variant="spring-up"] [data-slot="leaving"] { transform: translateY(calc(var(--h-travel, 18px) * -1)); opacity: 0; filter: blur(var(--h-blur, 0px)); } [data-variant="spring-up"][data-swapping="true"] [data-slot="entering"] { transform: translateY(var(--h-travel, 18px)); opacity: 0; filter: blur(var(--h-blur, 0px)); transition-duration: 0ms !important; } [data-variant="spring-up"][data-swapping="true"] [data-slot="leaving"] { transform: translateY(0); opacity: 1; filter: blur(0); transition-duration: 0ms !important; } /* ── 2. spring-down ─────────────────────────────────────────────── * * New text drops from above, old text exits downward. */ [data-variant="spring-down"] [data-slot="entering"], [data-variant="spring-down"] [data-slot="leaving"] { transition-property: transform, opacity, filter; transition-duration: var(--h-duration, 600ms), calc(var(--h-duration-raw, 600) * 0.6 * 1ms), calc(var(--h-duration-raw, 600) * 0.5 * 1ms); transition-timing-function: var(--h-spring), ease-out, ease-out; } [data-variant="spring-down"] [data-slot="entering"] { transform: translateY(0); opacity: 1; filter: blur(0); } [data-variant="spring-down"] [data-slot="leaving"] { transform: translateY(var(--h-travel, 18px)); opacity: 0; filter: blur(var(--h-blur, 0px)); } [data-variant="spring-down"][data-swapping="true"] [data-slot="entering"] { transform: translateY(calc(var(--h-travel, 18px) * -1)); opacity: 0; filter: blur(var(--h-blur, 0px)); transition-duration: 0ms !important; } [data-variant="spring-down"][data-swapping="true"] [data-slot="leaving"] { transform: translateY(0); opacity: 1; filter: blur(0); transition-duration: 0ms !important; } /* ── 3. spring-pop ──────────────────────────────────────────────── * * Scale + slight vertical shift + blur. Playful, bouncy. */ [data-variant="spring-pop"] [data-slot="entering"], [data-variant="spring-pop"] [data-slot="leaving"] { transition-property: transform, opacity, filter; transition-duration: var(--h-duration, 600ms), calc(var(--h-duration-raw, 600) * 0.55 * 1ms), calc(var(--h-duration-raw, 600) * 0.55 * 1ms); transition-timing-function: var(--h-spring), ease-out, ease-out; transform-origin: left center; } [data-variant="spring-pop"] [data-slot="entering"] { transform: translateY(0) scale(1); opacity: 1; filter: blur(0); } [data-variant="spring-pop"] [data-slot="leaving"] { transform: translateY(calc(var(--h-travel, 18px) * -0.35)) scale(0.92); opacity: 0; filter: blur(var(--h-blur, 3px)); } [data-variant="spring-pop"][data-swapping="true"] [data-slot="entering"] { transform: translateY(calc(var(--h-travel, 18px) * 0.35)) scale(0.92); opacity: 0; filter: blur(var(--h-blur, 3px)); transition-duration: 0ms !important; } [data-variant="spring-pop"][data-swapping="true"] [data-slot="leaving"] { transform: translateY(0) scale(1); opacity: 1; filter: blur(0); transition-duration: 0ms !important; } /* ── 4. spring-blur ─────────────────────────────────────────────── * * Pure crossfade with heavy blur. No vertical movement. * * Width still animates with spring. */ [data-variant="spring-blur"] [data-slot="entering"], [data-variant="spring-blur"] [data-slot="leaving"] { transition-property: opacity, filter; transition-duration: calc(var(--h-duration-raw, 600) * 0.75 * 1ms), var(--h-duration, 600ms); transition-timing-function: ease-out, var(--h-spring-soft); } [data-variant="spring-blur"] [data-slot="entering"] { opacity: 1; filter: blur(0); } [data-variant="spring-blur"] [data-slot="leaving"] { opacity: 0; filter: blur(calc(var(--h-blur, 4px) * 2)); } [data-variant="spring-blur"][data-swapping="true"] [data-slot="entering"] { opacity: 0; filter: blur(calc(var(--h-blur, 4px) * 2)); transition-duration: 0ms !important; } [data-variant="spring-blur"][data-swapping="true"] [data-slot="leaving"] { opacity: 1; filter: blur(0); transition-duration: 0ms !important; } /* ── 5. odometer ──────────────────────────────────────────────── * * Both texts scroll vertically through a clipped track. * * * * overflow:hidden clips at the padding-box edge. * * mask-image fades to transparent at that same edge. * * Result: content is invisible at the clip boundary → no hard * * edge ever visible. Padding + mask height extend the clip area * * so text has room to travel through the gradient fade zone. * * * * Uses transparent→white which works in both alpha & luminance * * mask modes (transparent=hidden, white=visible in both). */ [data-variant="odometer"] [data-slot="track"] { --h-mask-stop: min(var(--h-mask-size, 20px), calc(50% - 0.5px)); --h-odo-shift: calc( 100% + var(--h-travel, 18px) + var(--h-mask-height, 0px) + max(calc(var(--h-mask-pad, 28px) - 28px), 0px) ); position: relative; align-items: stretch; overflow: hidden; padding-block: calc(var(--h-mask-pad, 28px) + var(--h-mask-height, 0px)); margin-block: calc((var(--h-mask-pad, 28px) + var(--h-mask-height, 0px)) * -1); -webkit-mask-image: linear-gradient( to bottom, transparent 0px, white var(--h-mask-stop), white calc(100% - var(--h-mask-stop)), transparent 100% ); mask-image: linear-gradient( to bottom, transparent 0px, white var(--h-mask-stop), white calc(100% - var(--h-mask-stop)), transparent 100% ); transition: width var(--h-duration, 600ms) var(--h-spring-soft, cubic-bezier(0.34, 1.1, 0.64, 1)); } /* on swap, jump width instantly to the max of both texts */ [data-variant="odometer"][data-swapping="true"] [data-slot="track"] { transition-duration: 0ms !important; } [data-variant="odometer"] [data-slot="entering"], [data-variant="odometer"] [data-slot="leaving"] { transition-property: transform; transition-duration: var(--h-duration, 600ms); transition-timing-function: var(--h-spring); opacity: 1; } /* settled: entering in view, leaving pushed below */ [data-variant="odometer"] [data-slot="entering"] { transform: translateY(0); } [data-variant="odometer"] [data-slot="leaving"] { transform: translateY(var(--h-odo-shift)); } /* swapping: snap entering above, leaving in-place */ [data-variant="odometer"][data-swapping="true"] [data-slot="entering"] { transform: translateY(calc(var(--h-odo-shift) * -1)); transition-duration: 0ms !important; } [data-variant="odometer"][data-swapping="true"] [data-slot="leaving"] { transform: translateY(0); transition-duration: 0ms !important; } /* ── odometer + blur ──────────────────────────────────────────── * * Optional: adds opacity + blur transitions on top of the * * positional odometer movement. */ [data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"], [data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] { transition-property: transform, opacity, filter; transition-duration: var(--h-duration, 600ms), calc(var(--h-duration-raw, 600) * 0.6 * 1ms), calc(var(--h-duration-raw, 600) * 0.5 * 1ms); } [data-variant="odometer"][data-odo-blur="true"] [data-slot="entering"] { opacity: 1; filter: blur(0); } [data-variant="odometer"][data-odo-blur="true"] [data-slot="leaving"] { opacity: 0; filter: blur(var(--h-blur, 4px)); } [data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="entering"] { opacity: 0; filter: blur(var(--h-blur, 4px)); } [data-variant="odometer"][data-odo-blur="true"][data-swapping="true"] [data-slot="leaving"] { opacity: 1; filter: blur(0); } /* ── debug: show fade zones ───────────────────────────────────── */ [data-variant="odometer"][data-debug="true"] [data-slot="track"] { outline: 1px dashed rgba(255, 0, 0, 0.6); } [data-variant="odometer"][data-debug="true"] [data-slot="track"]::before, [data-variant="odometer"][data-debug="true"] [data-slot="track"]::after { content: ""; position: absolute; left: 0; right: 0; height: var(--h-mask-stop); pointer-events: none; } [data-variant="odometer"][data-debug="true"] [data-slot="track"]::before { top: 0; background: linear-gradient(to bottom, rgba(255, 0, 0, 0.3), transparent); } [data-variant="odometer"][data-debug="true"] [data-slot="track"]::after { bottom: 0; background: linear-gradient(to top, rgba(255, 0, 0, 0.3), transparent); } /* ── slider styling ─────────────────────────────────────────────── */ input[type="range"].heading-slider { -webkit-appearance: none; appearance: none; width: 140px; height: 4px; border-radius: 2px; background: var(--color-divider, #444); outline: none; } input[type="range"].heading-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--color-accent, #58f); cursor: pointer; border: none; } ` // --------------------------------------------------------------------------- // Animated heading component // // Width is measured via scrollWidth (NOT Range.getBoundingClientRect) because // getBoundingClientRect includes CSS transforms — so scale(0.92) during the // swap phase would measure 92% of the real width and permanently clip text. // scrollWidth returns the layout/intrinsic width, unaffected by transforms. // --------------------------------------------------------------------------- function AnimatedHeading(props) { const [state, setState] = createStore({ current: props.text, leaving: undefined, width: "auto", ready: false, swapping: false, }) const current = () => state.current const leaving = () => state.leaving const width = () => state.width const ready = () => state.ready const swapping = () => state.swapping let enterRef let leaveRef let containerRef let frame const measureEnter = () => enterRef?.scrollWidth ?? 0 const measureLeave = () => leaveRef?.scrollWidth ?? 0 const widen = (px) => { if (px <= 0) return const w = Number.parseFloat(width()) if (Number.isFinite(w) && px <= w) return setState("width", `${px}px`) } const measure = () => { if (!current()) { setState("width", "0px") return } const px = measureEnter() if (px > 0) setState("width", `${px}px`) } createEffect( on( () => props.text, (next, prev) => { if (next === prev) return setState("swapping", true) setState("leaving", prev) setState("current", next) if (frame) cancelAnimationFrame(frame) frame = requestAnimationFrame(() => { // For odometer keep width as a grow-only max so heading never shrinks. if (props.variant === "odometer") { const enterW = measureEnter() const leaveW = measureLeave() widen(Math.max(enterW, leaveW)) containerRef?.offsetHeight // reflow with max width + swap positions setState("swapping", false) } else { containerRef?.offsetHeight setState("swapping", false) measure() } frame = undefined }) }, ), ) onMount(() => { measure() document.fonts?.ready.finally(() => { measure() requestAnimationFrame(() => setState("ready", true)) }) }) onCleanup(() => { if (frame) cancelAnimationFrame(frame) }) return ( {current() ?? "\u00A0"} {leaving() ?? "\u00A0"} ) } // --------------------------------------------------------------------------- // Button / layout styles // --------------------------------------------------------------------------- const btn = (accent) => ({ padding: "6px 14px", "border-radius": "6px", border: "1px solid var(--color-divider, #333)", background: accent ? "var(--color-danger-fill, #c33)" : "var(--color-fill-element, #222)", color: "var(--color-text, #eee)", cursor: "pointer", "font-size": "13px", }) const smallBtn = (active) => ({ padding: "4px 12px", "border-radius": "6px", border: active ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)", background: active ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)", color: "var(--color-text, #eee)", cursor: "pointer", "font-size": "12px", }) const sliderLabel = { "font-size": "11px", "font-family": "monospace", color: "var(--color-text-weak, #666)", "min-width": "70px", "flex-shrink": "0", "text-align": "right", } const sliderValue = { "font-family": "monospace", "font-size": "11px", color: "var(--color-text-weak, #aaa)", "min-width": "60px", } const cardLabel = { "font-size": "11px", "font-family": "monospace", color: "var(--color-text-weak, #666)", } const thinkingRow = { display: "flex", "align-items": "center", gap: "8px", "min-width": "0", "font-size": "14px", "font-weight": "500", "line-height": "20px", "min-height": "20px", color: "var(--text-weak, #aaa)", } const headingSlot = { "min-width": "0", overflow: "visible", "white-space": "nowrap", color: "var(--text-weaker, #888)", "font-weight": "400", } const cardStyle = { padding: "16px 20px", "border-radius": "10px", border: "1px solid var(--color-divider, #333)", background: "var(--h-mask-bg, #1a1a1a)", display: "grid", gap: "8px", } // --------------------------------------------------------------------------- // Variants // --------------------------------------------------------------------------- const VARIANTS: { key: string; label: string }[] = [] // --------------------------------------------------------------------------- // Story // --------------------------------------------------------------------------- export const Playground = { render: () => { const [state, setState] = createStore({ heading: HEADINGS[0], headingIndex: 0, active: true, cycling: false, duration: 550, blur: 2, travel: 4, bounce: 1.35, maskSize: 12, maskPad: 9, maskHeight: 0, debug: false, odoBlur: false, }) const heading = () => state.heading const headingIndex = () => state.headingIndex const active = () => state.active const cycling = () => state.cycling const duration = () => state.duration const blur = () => state.blur const travel = () => state.travel const bounce = () => state.bounce const maskSize = () => state.maskSize const maskPad = () => state.maskPad const maskHeight = () => state.maskHeight const debug = () => state.debug const odoBlur = () => state.odoBlur let cycleTimer const nextHeading = () => { const next = (headingIndex() + 1) % HEADINGS.length setState("headingIndex", next) setState("heading", HEADINGS[next]) } const prevHeading = () => { const prev = (headingIndex() - 1 + HEADINGS.length) % HEADINGS.length setState("headingIndex", prev) setState("heading", HEADINGS[prev]) } const toggleCycling = () => { if (cycling()) { clearTimeout(cycleTimer) cycleTimer = undefined setState("cycling", false) return } setState("cycling", true) const tick = () => { if (!cycling()) return nextHeading() cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550)) } cycleTimer = setTimeout(tick, 850 + Math.floor(Math.random() * 550)) } const clearHeading = () => { setState("heading", undefined) if (cycling()) { clearTimeout(cycleTimer) cycleTimer = undefined setState("cycling", false) } } onCleanup(() => { if (cycleTimer) clearTimeout(cycleTimer) }) const vars = () => ({ "--h-duration": `${duration()}ms`, "--h-duration-raw": `${duration()}`, "--h-blur": `${blur()}px`, "--h-travel": `${travel()}px`, "--h-spring": `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`, "--h-spring-soft": `cubic-bezier(0.34, ${Math.max(bounce() * 0.7, 1)}, 0.64, 1)`, "--h-mask-size": `${maskSize()}px`, "--h-mask-pad": `${maskPad()}px`, "--h-mask-height": `${maskHeight()}px`, "--h-mask-bg": "#1a1a1a", }) return (
{/* ── Variant cards ─────────────────────────────────── */}
TextReveal (production)
{VARIANTS.map((v) => (
{v.label}
))}
{/* ── Sliders ──────────────────────────────────────── */}
duration setState("duration", Number(e.currentTarget.value))} /> {duration()}ms
blur setState("blur", Number(e.currentTarget.value))} /> {blur()}px
travel setState("travel", Number(e.currentTarget.value))} /> {travel()}px
bounce setState("bounce", Number(e.currentTarget.value))} /> {bounce().toFixed(2)} {bounce() <= 1.05 ? "(none)" : bounce() >= 1.9 ? "(heavy)" : ""}
mask setState("maskSize", Number(e.currentTarget.value))} /> {maskSize()}px {maskSize() === 0 ? "(hard)" : ""}
mask pad setState("maskPad", Number(e.currentTarget.value))} /> {maskPad()}px
mask height setState("maskHeight", Number(e.currentTarget.value))} /> {maskHeight()}px
{/* ── Controls ─────────────────────────────────────── */}
{HEADINGS.map((h, i) => ( ))}
heading: {heading() ?? "(none)"} · sim: {cycling() ? "on" : "off"} · bounce: {bounce().toFixed(2)} · odo-blur: {odoBlur() ? "on" : "off"}
) }, }