Subframe
Components & interactionsBackground task progress pill

Activity capsule

An export collapses into a small persistent capsule without losing its progress.

Render, collapse or expand the task, cancel, and restart.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

"use client"
import * as React from "react"
function usePreview(active: boolean) {
  const [reduced, setReduced] = React.useState(true),
    [instant, setInstant] = React.useState(false)
  React.useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const read = () => setReduced(q.matches)
    read()
    q.addEventListener("change", read)
    return () => q.removeEventListener("change", read)
  }, [])
  return {
    still: !active || reduced || instant,
    reduced,
    onKeyDownCapture: () => setInstant(true),
    onPointerDownCapture: () => setInstant(false),
  }
}

export default function Example({ active = true }: { active?: boolean }) {
  const preview = usePreview(active)

  const [expanded, setExpanded] = React.useState(true),
    [progress, setProgress] = React.useState(36),
    [phase, setPhase] = React.useState<"idle" | "running" | "cancelled" | "done">("idle")
  React.useEffect(() => {
    if (!active || phase !== "running") return
    const t = setTimeout(() => {
      if (progress >= 88) {
        setProgress(100)
        setPhase("done")
      } else setProgress((p) => p + 13)
    }, 500)
    return () => clearTimeout(t)
  }, [phase, progress, active])
  const start = () => {
    setProgress(0)
    setPhase("running")
  }

  return (
    <section
      className="sf-w2-activity-capsule"
      data-still={preview.still}
      onKeyDownCapture={preview.onKeyDownCapture}
      onPointerDownCapture={preview.onPointerDownCapture}
    >
      <div className="capsule" data-expanded={expanded}>
        <button type="button" className="summary" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
          <span className="glyph" aria-hidden>
            {phase === "done" ? "✓" : phase === "cancelled" ? "×" : "↗"}
          </span>
          <span>
            {expanded
              ? "Export collection"
              : phase === "running"
              ? `Exporting · ${progress}%`
              : phase === "done"
              ? "Export ready"
              : phase === "cancelled"
              ? "Export cancelled"
              : "Export collection"}
          </span>
          <span className="chevron" aria-hidden>
            {expanded ? "−" : "+"}
          </span>
        </button>
        {expanded && (
          <div className="details">
            <div className="file">
              <div className="cover" aria-hidden>
                <i />
              </div>
              <div>
                <b>After hours</b>
                <span>12 frames · PNG</span>
              </div>
              <span className="size">28 MB</span>
            </div>
            <div
              className="segments"
              role="progressbar"
              aria-label="Export progress"
              aria-valuemin={0}
              aria-valuemax={100}
              aria-valuenow={phase === "idle" ? 0 : progress}
            >
              {Array.from({ length: 24 }, (_, i) => (
                <i key={i} data-on={phase !== "idle" && i < (progress / 100) * 24} />
              ))}
            </div>
            <div className="row between footer">
              <span role="status">
                {phase === "running"
                  ? `${progress}% rendered`
                  : phase === "done"
                  ? "Ready to save"
                  : phase === "cancelled"
                  ? "Stopped"
                  : "Local render preview"}
              </span>
              <button type="button" onClick={() => (phase === "running" ? setPhase("cancelled") : start())}>
                {phase === "running" ? "Cancel" : phase === "idle" ? "Render" : "Render again"}
              </button>
            </div>
          </div>
        )}
      </div>

      <style>{`.sf-w2-activity-capsule{position:relative;isolation:isolate;container-type:inline-size;width:100%;height:100%;min-height:280px;overflow:hidden;box-sizing:border-box;font:13px/1.4 Arial,sans-serif;-webkit-font-smoothing:antialiased}.sf-w2-activity-capsule *{box-sizing:border-box}.sf-w2-activity-capsule button,.sf-w2-activity-capsule input,.sf-w2-activity-capsule select,.sf-w2-activity-capsule textarea{font:inherit}.sf-w2-activity-capsule button{cursor:pointer;touch-action:manipulation;min-height:44px;border:0;background:none;color:inherit}.sf-w2-activity-capsule button:disabled{opacity:.4;cursor:default}.sf-w2-activity-capsule button:active:not(:disabled){transform:scale(.98)}.sf-w2-activity-capsule input,.sf-w2-activity-capsule textarea{font-size:16px;min-width:0}.sf-w2-activity-capsule button:focus-visible,.sf-w2-activity-capsule input:focus-visible,.sf-w2-activity-capsule select:focus-visible,.sf-w2-activity-capsule textarea:focus-visible,.sf-w2-activity-capsule [tabindex]:focus-visible{outline:2px solid #3376ef;outline-offset:3px}.sf-w2-activity-capsule h2,.sf-w2-activity-capsule p{margin:0}.sf-w2-activity-capsule .row{display:flex;align-items:center;gap:12px}.sf-w2-activity-capsule .between{justify-content:space-between}.sf-w2-activity-capsule .muted{opacity:.6}.sf-w2-activity-capsule .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.sf-w2-activity-capsule[data-still=true] *,.sf-w2-activity-capsule[data-still=true] *::before,.sf-w2-activity-capsule[data-still=true] *::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}@media(prefers-reduced-motion:reduce){.sf-w2-activity-capsule *,.sf-w2-activity-capsule *::before,.sf-w2-activity-capsule *::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}@media(hover:hover) and (pointer:fine){.sf-w2-activity-capsule button:hover:not(:disabled){filter:brightness(.96)}}.sf-w2-activity-capsule{display:flex;flex-direction:column;align-items:center;justify-content:center;background:#d5d8dc;color:#f1f0ed;padding:20px}.sf-w2-activity-capsule .capsule{width:min(100%,365px);background:#24262a;border-radius:17px;border:1px solid #1c1e20;box-shadow:0 2px 3px #1115,0 18px 38px #17212d20;overflow:hidden;transition:width .25s cubic-bezier(.2,.8,.2,1),border-radius .25s}.sf-w2-activity-capsule .capsule[data-expanded=false]{width:230px;border-radius:28px}.sf-w2-activity-capsule .summary{display:flex;width:100%;align-items:center;gap:10px;height:57px;padding:0 20px;text-align:left;font-size:12px}.sf-w2-activity-capsule .glyph{color:#e6b77b;font-size:19px}.sf-w2-activity-capsule .chevron{margin-left:auto;color:#8e9299}.sf-w2-activity-capsule .details{padding:0 20px 15px;animation:activity-w2 .18s ease-out}.sf-w2-activity-capsule .file{display:flex;align-items:center;gap:12px;padding:12px 0 22px;border-top:1px solid #3d4148}.sf-w2-activity-capsule .cover{height:48px;width:37px;background:#cec4ae;position:relative;overflow:hidden;flex-shrink:0}.sf-w2-activity-capsule .cover i{position:absolute;border:5px solid #31342f;border-radius:50%;width:30px;height:60px;left:15px;top:-5px;transform:rotate(25deg)}.sf-w2-activity-capsule .file b{display:block;font:19px Georgia,serif;color:#eee6d7}.sf-w2-activity-capsule .file span{display:block;font-size:10px;color:#858b95;margin-top:4px}.sf-w2-activity-capsule .file>.size{margin-left:auto;white-space:nowrap}.sf-w2-activity-capsule .segments{display:flex;gap:3px;height:14px}.sf-w2-activity-capsule .segments i{flex:1;background:#383d44;transition:background .16s}.sf-w2-activity-capsule .segments i[data-on=true]{background:#ce9f64}.sf-w2-activity-capsule .footer{margin-top:6px;font-size:10px;color:#a1a6af}.sf-w2-activity-capsule .footer button{color:#e9d8be;font-size:11px;padding:0 6px}@keyframes activity-w2{from{opacity:0}to{opacity:1}}`}</style>
    </section>
  )
}

Where it works well

Keep one background task available while the rest of an interface stays clear.

Reduced motion

Expansion and progress segments update immediately; the finite task still runs.

Things to know

A bounded local render simulation, not a real export or downloadable artifact.

More examples