Kinematicv0.1

Docs

PlaygroundShowcaseDocsGitHub
IntroductionQuick startActivity
IntroductionQuick startActivity

Activity

One surface that shows what's happening. Compact when you glance, open when you look, and it morphs between whatever is running.

How it works

Every activity has two shapes. Compact is a small pill: an icon, a line of text, maybe a control. Expanded is the full card. Click the pill to open it. Click anywhere outside, press Escape or use the arrow to close it.

Anything that appears in both shapes, like a file's icon or a song's cover, keeps its identity. It travels from the pill into the card instead of disappearing and reappearing. That continuity is the whole idea.

Activities are shown by one ActivityViewport, and you start them from anywhere with the activity object or one of the ready-made helpers below.

Layouts

Pass layout to the viewport to choose how several activities share the screen.

<ActivityViewport layout="single" />
  • single: one pill at a time. Start something new and the pill morphs into it. The previous one keeps running out of sight, so music keeps playing, and it comes back when you dismiss the current one.
  • stack: each activity gets its own pill, stacked toward the nearest edge. This is the default.
  • island: the activity you're focused on is the full pill, and the rest shrink into round bubbles beside it. Click a bubble to swap it in. Past three bubbles, a +N bubble holds the rest.

position places the viewport: bottom-right (default), bottom-center, bottom-left, top-right, top-center or top-left.

Tasks

Tasks are work with steps: uploads, exports, builds, an agent's run. Each item shows its own progress, and one failing doesn't stop the rest.

import { FileText } from "lucide-react"
import { activity } from "@/components/ui/activity"

activity.track({
  title: "Importing 3 documents",
  items: files.map((file) => ({
    id: file.name,
    label: file.name,
    meta: formatSize(file.size),
    icon: <FileText />,
    run: (progress) => upload(file, { onProgress: progress }),
  })),
  outcome: (task) => ({
    label: `${task.items.length} documents ready`,
    action: { label: "Open", onClick: openDocuments },
  }),
})
  • run(progress, signal) returns a promise. Call progress with a number from 0 to 1 as it goes. signal aborts if the user dismisses the task, so pass it to fetch and the work stops with it.
  • If run throws, that item fails with the error's message and gets a Retry button. Collapsed, the pill reads "2 ready · 1 needs attention".
  • When every item is done, the activity settles into outcome, folds back into a pill, and shows its action.

For an agent, pass sequential so steps run one after another. A failed step pauses the rest until it's retried.

import { BookOpen, PenLine, Search } from "lucide-react"
import { activity } from "@/components/ui/activity"

activity.track({
  title: "Researching usage pricing",
  sequential: true,
  items: [
    { id: "search", label: "Searching the web", icon: <Search />, run: search },
    { id: "read", label: "Reading 8 papers", icon: <BookOpen />, run: read },
    { id: "write", label: "Writing the report", icon: <PenLine />, run: write },
  ],
  outcome: () => ({ label: "Report ready", action: { label: "Read", onClick: openReport } }),
})

When progress arrives as events, from a websocket or an agent's stream, use start and update it yourself.

import { activity } from "@/components/ui/activity"

const task = activity.start({ title: "Deploying", items: steps })

task.progress("build", 0.4)
task.complete("build")
task.fail("check", { error: "1 type error", retry: rerunChecks })
task.finish({ label: "Live", action: { label: "Visit", onClick: visit } })

Music

pnpm dlx shadcn@latest add @kinematic/activity-player
import { showPlayer } from "@/components/ui/activity-player"

showPlayer({ src: "/song.mp3", title: "Afterglow", artist: "Kinematic FM", cover: "/cover.jpg" })

The pill shows the cover, the title and small level bars that follow the song. Open it for a waveform drawn from the audio itself, with seeking and ±10 second skips. Playback lives as long as the activity, so it keeps going while another one holds the pill.

Calls

pnpm dlx shadcn@latest add @kinematic/activity-call
import { startCall } from "@/components/ui/activity-call"

const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const call = startCall({ name: "Ada", stream })

agent.on("connected", () => call.connect())
agent.on("level", (value) => call.setLevel("agent", value))
agent.on("message", ({ role, text }) => call.say(role, text))
agent.on("end", () => call.end())

Pass a microphone stream and the bars follow your voice. Give each call its own id if you run more than one. Feed the agent's level with setLevel. say streams a line into the transcript word by word. Mute and End are built in.

Gradient orb

Without an avatar, the call shows a mesh gradient orb made from the caller's name. The same name always gets the same orb, and it slowly drifts while the agent speaks. It's its own registry item, so you can use it anywhere you need a default avatar.

pnpm dlx shadcn@latest add @kinematic/gradient-orb
import { GradientOrb } from "@/components/ui/gradient-orb"

<GradientOrb seed={user.email} className="size-10" />
<GradientOrb seed="Ada" animated className="size-16" />

Timers

pnpm dlx shadcn@latest add @kinematic/activity-timer
import { startTimer } from "@/components/ui/activity-timer"

const timer = startTimer({ label: "Focus", minutes: 25, onDone: notify })

timer.pause()
timer.resume()
timer.add(5)

A ring empties in the pill. Open it and the digits roll as they count down.

Theming

Activity has its own colour tokens. Each one falls back to your shadcn theme, so it matches your app out of the box. Override any of them and every activity follows.

TokenUsed forFalls back to
--activity-backgroundThe surface--popover
--activity-foregroundText, icons and solid buttons--popover-foreground
--activity-mutedTiles and soft fills--muted
--activity-muted-foregroundSecondary text--muted-foreground
--activity-borderThe surface outline--border

Inverted

For a surface that stands out from the page, dark on a light page and light on a dark one, use the built-in tone.

<ActivityViewport tone="inverted" />

Your own colours

Set the tokens anywhere above the viewport: in your global CSS, on a wrapper, or right on the viewport.

app/globals.css
:root {
  --activity-background: oklch(0.21 0.03 265);
  --activity-foreground: oklch(0.98 0 0);
  --activity-muted: oklch(0.28 0.03 265);
  --activity-muted-foreground: oklch(0.72 0.02 265);
  --activity-border: oklch(0.3 0.03 265);
}

.dark {
  --activity-background: oklch(0.97 0.01 265);
  --activity-foreground: oklch(0.2 0.03 265);
  --activity-muted: oklch(0.92 0.01 265);
  --activity-muted-foreground: oklch(0.45 0.02 265);
  --activity-border: oklch(0.88 0.01 265);
}

Or scope them to one viewport with Tailwind:

<ActivityViewport className="[--activity-background:var(--color-zinc-900)] [--activity-foreground:white]" />

The width comes from --activity-width, which defaults to min(22rem, 100vw - 2rem).

Inside your own activities, use the matching Tailwind colours so they follow the theme too: bg-activity, text-activity-foreground, bg-activity-muted, text-activity-muted-foreground and border-activity-border.

Build your own

Every activity above is built from the same parts, and so is yours. Give two elements the same name in ActivityShared and they become one element that travels between shapes.

import {
  activity,
  ActivityCollapse,
  ActivityCompact,
  ActivityExpanded,
  ActivityGlyph,
  ActivityReveal,
  ActivityShared,
  ActivityTrigger,
} from "@/components/ui/activity"

function Delivery() {
  return (
    <>
      <ActivityGlyph label="Your order">
        <ActivityShared name="icon" radius={18} className="size-9 bg-muted" />
      </ActivityGlyph>
      <ActivityCompact>
        <ActivityTrigger>
          <ActivityShared name="icon" radius={18} className="size-9 bg-muted" />
          <ActivityShared name="eta" className="text-sm font-medium">4 min away</ActivityShared>
        </ActivityTrigger>
      </ActivityCompact>
      <ActivityExpanded>
        <div className="flex items-center gap-3">
          <ActivityShared name="icon" radius={12} className="size-12 bg-muted" />
          <ActivityShared name="eta" className="text-lg font-medium">4 min away</ActivityShared>
          <ActivityCollapse />
        </div>
        <ActivityReveal>Your driver is two streets away.</ActivityReveal>
      </ActivityExpanded>
    </>
  )
}

activity.show({ id: "delivery", view: Delivery })
PartWhat it does
ActivityCompactThe pill. Shown when the activity is closed.
ActivityExpandedThe card. Shown when it's open.
ActivitySharedAn element that travels between shapes. Match them by name; radius lets the corners change on the way.
ActivityRevealContent that fades in once the card has settled.
ActivityGlyphWhat the activity shrinks to as a bubble in island.
ActivityTriggerOpens and closes it.
ActivityCollapse, ActivityDismissReady-made close and dismiss buttons.

For live data, createActivityState makes a small store you can update from outside React, and useActivityState reads it inside your view. activity.show returns a handle with expand, collapse and dismiss, and takes onDismiss to clean up.

You can also render <Activity> inline, outside the viewport, with expanded and onExpandedChange.

Troubleshooting

In development, Kinematic warns in the console about the common setup mistakes below.

  • Nothing appears when I start an activity. No ActivityViewport is mounted. Render it once near the root of your app, for example in app/layout.tsx.
  • Every activity shows up twice. Two viewports are mounted, often one in a layout and one on a page. Keep a single one.
  • "Activity parts must be rendered inside an activity". A part such as ActivityCompact or ActivityShared was rendered on its own. Put it inside the view you pass to activity.show({ view }), or inside an inline <Activity>.
  • The waveform is a plain bar. The audio couldn't be fetched for decoding, usually because of CORS. Playback still works.
  • Colours don't match my theme. The --activity-* tokens fall back to --popover, --muted and --border. Check those exist in your CSS, or set the tokens directly (see Theming).

API reference

ActivityViewport

PropTypeDefaultDescription
layout"stack" | "single" | "island""stack"How several activities share the screen.
position"bottom-right" | "bottom-center" | "bottom-left" | "top-right" | "top-center" | "top-left""bottom-right"Where activities appear. They grow away from this edge.
tone"default" | "inverted""default"inverted flips the surface against the page.
classNamestringExtra classes, such as absolute to place it inside a container, or token overrides.
styleCSSPropertiesInline styles and token overrides.

activity

MethodReturnsDescription
track(options)ActivityHandleRuns each item's run, tracks progress, adds Retry on failure and settles into outcome.
start(options)ActivityHandleShows a task you update yourself.
show({ id?, view, onDismiss? })ActivityViewHandleShows a custom activity. Reusing an id replaces it.
dismiss(id)voidRemoves one activity.
clear()voidRemoves every activity.

track options:

OptionTypeDescription
titlestringShown while it runs.
items{ id, label, meta?, icon?, run }[]run(progress, signal) returns a promise. Call progress with 0 to 1. Throw to fail the item. signal aborts when the task is dismissed.
sequentialbooleanRun items one after another. A failure pauses the rest until it's retried.
outcome(task) => { label, action? }What it settles into once every item is done.

ActivityHandle has progress(itemId, value), complete(itemId), fail(itemId, { error, retry? }), update(itemId, patch), finish({ label, action? }), expand(), collapse() and dismiss(). ActivityViewHandle has expand(), collapse() and dismiss().

showPlayer

OptionTypeDescription
srcstringAudio URL. It's also fetched and decoded once to draw the waveform, so files on another origin need CORS. Without it, the player still plays and shows a plain progress bar.
title, artiststringShown in the pill and the card.
coverstringOptional cover image URL.
idstringDefaults to "now-playing".

Returns the view handle plus toggle(), pause(), seek(ratio), skip(seconds), setMuted(muted) and preview(), which animates playback silently for demos.

startCall

OptionTypeDescription
namestringWho you're talking to.
avatarstringOptional image URL. A gradient orb made from name is used otherwise.
streamMediaStreamOptional microphone stream. Its level drives the bars, and it's stopped when the call ends.
idstringDefaults to "call".
onEnd() => voidCalled once when the call ends, including when a live call is dismissed or replaced.

Returns the view handle plus connect(), say(speaker, text), setLevel(speaker, value), mute(muted), end() and isEnded(). speaker is "agent" or "user".

startTimer

OptionTypeDefaultDescription
labelstring"Focus"Shown in the pill.
minutesnumber25Length of the countdown.
idstring"timer"
onDone() => voidCalled once when it finishes.

Returns the view handle plus pause(), resume(), add(minutes) and end().

GradientOrb

PropTypeDefaultDescription
seedstringAny string. It decides the colours and their layout, and doubles as the accessible label.
animatedbooleanfalseSlowly drifts the gradient. Paused for reduced motion.
classNamestringSize it here, for example size-10.

orbPalette(seed) returns the generated colours if you want to reuse them elsewhere.

Parts

PartProps
Activityexpanded, defaultExpanded, onExpandedChange, onDismiss, closeOnOutsideClick (default true), className. For using one inline, outside the viewport.
ActivitySharedname (required), text to move without resizing (use it for any text that changes), radius, className, style.
ActivityRevealdelay in seconds, className.
ActivityGlyphlabel (required), used for its accessible name as a bubble.
ActivityButtonEvery button prop, plus variant (ghost, soft, solid, danger) and size (icon-sm, icon-md, icon-lg, sm, lg).
ActivityProgressvalue from 0 to 1, className.
ActivityAnnouncechildren: a short message for screen readers. Change it when something worth hearing happens.

Accessibility

Activities never steal focus. Each one announces through a polite, visually hidden status message instead of reading the whole surface on every change: a task speaks up when something needs attention and when it settles, a call when it connects, mutes or ends, and a timer when it pauses or finishes. Progress ticks and the running clock stay quiet.

In your own activities, render ActivityAnnounce with a short message and change it when something worth hearing happens:

<ActivityAnnounce>{status === "arrived" ? "Your order has arrived" : ""}</ActivityAnnounce>

Status is never color alone: failures are written out with a Retry button, and "needs attention" comes with an icon. The player's waveform is a slider: arrow keys skip 5 seconds, Home and End jump to the start and end. With reduced motion, shapes change instantly and nothing slides or rolls.