# Activity Feed (/docs/blocks/activity-feed) ```tsx title="components/examples/activity-feed-demo.tsx" "use client"; import { useState } from "react"; import { ActivityFeed } from "@/components/blocks/stream-manager/activity-feed"; import type { ActivityItem } from "@/components/blocks/stream-manager/activity-feed"; import { StatusDot } from "@/components/status-dot"; import { Badge } from "@/components/ui/badge"; import { Empty, EmptyDescription, EmptyHeader } from "@/components/ui/empty"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; const ITEMS: ActivityItem[] = [ { highlight: { phase: "live" }, id: "a1", kind: "super-chat", line: "Sent a $20.00 Super Chat", message: "Bought my first capture card because of this channel!", messageId: "m-super", name: "Maya Chen", platform: "youtube", short: "Super Chat · $20.00", time: "now", }, { id: "a2", kind: "subscription", line: "Resubscribed for 14 months at Tier 1", message: "Fourteen months of two-camera streams.", name: "nightowl_jonas", platform: "twitch", short: "Resub · 14 months", time: "1m", }, { gift: true, id: "a3", kind: "subscription", line: "Gifted 5 Tier 1 subs to the community", name: "kai.streams", platform: "twitch", short: "Gifted 5 subs", time: "3m", }, { id: "a4", kind: "raid", line: "Raided with 42 viewers", name: "pixelpilot", platform: "twitch", short: "Raid · 42 viewers", time: "6m", }, { id: "a5", kind: "follow", line: "3 new followers on X", name: "3 new followers", platform: "x", short: "", time: "9m", unnamed: true, }, { id: "a6", kind: "follow", line: "Subscribed to your channel", name: "Hana Ito", platform: "youtube", short: "New subscriber", time: "12m", }, ]; const ActivityFeedDemo = () => { const [pane, setPane] = useState("activity"); const [autoShow, setAutoShow] = useState(true); return (
setPane(String(value))} value={pane}> Activity Orcle 1
{pane === "activity" ? ( null} onThank={() => null} platforms={["youtube", "twitch", "x"]} /> ) : ( Buddy reads chat during a live stream: questions, flags and the room's mood. )}
); }; export default ActivityFeedDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/activity-feed ``` ## Usage ```tsx import { ActivityFeed } from "@/components/blocks/stream-manager/activity-feed"; import type { ActivityItem } from "@/components/blocks/stream-manager/activity-feed"; ``` ```tsx const items: ActivityItem[] = [ { id: "a1", kind: "subscription", platform: "twitch", name: "nightowl_jonas", short: "Resub · 14 months", line: "Resubscribed for 14 months at Tier 1", message: "Fourteen months of two-camera streams.", time: "1m", }, ]; ; ``` Activity is structured events, never chat text. Each event is plain data: who (`name`), the fact at a glance (`short`: "Resub · 14 months"), the sentence (`line`, on hover and for Copy), the viewer's own words, and the time as you show it ("now", "2m"). The pane derives each event's kind chip from its `kind`. - **A row leads with who.** An event about one named viewer or channel shows their avatar, or their initials until it loads. Counts the platform never named (`unnamed`), announcements and destinations keep their glyph. - **One tint per row,** like chat: a channel point reward paints `bg-reward/8`, a tip chat's paid fill without its ring, and a sub or membership the member plate. The row on stream wears the live plate instead. - **The ⋯ menu** holds Show on stream (with a `messageId`), Thank in chat, Reconnect Twitch on an unnamed Twitch count, and Copy. The pane's tabs, Activity beside Buddy, belong to the window: the [Stream Manager](/docs/blocks/stream-manager) puts them over the pane, as the demo does. ## Examples ### Empty, with what a platform can't share Until something happens the pane says what will show, and `note` says what never can on these platforms: `activityCapabilityNote(platforms, options)` writes it. With `twitchNeeds.followNames` and `onReconnectTwitch`, Reconnect Twitch asks for the permission that names each follower. ```tsx title="components/examples/activity-feed-empty.tsx" "use client"; import { ActivityFeed, activityCapabilityNote, } from "@/components/blocks/stream-manager/activity-feed"; import type { ChatPlatform } from "@/components/blocks/stream-manager/chat-platform"; const PLATFORMS: ChatPlatform[] = ["youtube", "twitch", "x"]; const ActivityFeedEmpty = () => (
null} platforms={PLATFORMS} twitchNeeds={{ followNames: true }} />
); export default ActivityFeedEmpty; ``` ## Responsive Inside the Stream Manager the filter strip follows the window's width: from 640 px the platform and kind chips are inline, with each kind's count, and below it they fold into one Filters menu. On its own the chips always show. ## Theming | Token or attribute | Meaning | | --- | --- | | `divide-y` | Flush rows split by hairlines | | `chrome-row-live` | The row on stream | | `chrome-row-member`, `bg-warning/10`, `bg-reward/8` | A sub or membership, a tip, a reward | | `hover:bg-accent` | Hover, over any tint | | `group/activity`, `group/kebab-host` | The row the time and the ⋯ wait for | | `data-kind`, `data-row-tint`, `data-highlight-phase` | On each row | | `data-slot` | `activity-feed`, `activity-row`, `activity-avatar`, `activity-glyph`, `activity-line` | ## Accessibility The pane is a region named "Activity", and its rows an ordered list. A row's sentence is what a screen reader hears when the short fact says less; on screen it's the fact's tooltip, never a native `title`. The kind chips are a group named "Filter activity", each explained on hover. Auto-show's switch is named by what it does. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `items` | `readonly ActivityItem[]` | `[]` | — | | `platforms` | `readonly ChatPlatform[]` | — | The platforms on this stream: the platform chips. | | `note` | `string \| null` | — | What Activity can't show here, under the empty line. | | `timestamps` | `"always" \| "hover"` | `"always"` | hover: a row's time waits for the pointer, as while live. | | `onShowOnStream` | `(item) => void` | — | Show on stream, for rows with a messageId. | | `onThank` | `(item) => void` | — | Thank in chat. | | `twitchNeeds` | `{ followNames?: boolean; bitsAndPoints?: boolean }` | — | The Twitch permissions still missing. | | `onReconnectTwitch` | `() => void` | — | Reconnect Twitch, wherever it's asked. | | `autoShow` | `boolean` | `false` | — | | `onAutoShowChange` | `(on: boolean) => void` | — | Shows the Auto-show switch. | ### ActivityItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | — | — | | `kind` | `"follow" \| "subscription" \| "membership" \| "cheer" \| "super-chat" \| "super-sticker" \| "power-up" \| "redemption" \| "raid" \| "watch-streak" \| "announcement" \| "destination-failed" \| "destination-recovered"` | — | — | | `platform` | `ChatPlatform` | — | — | | `name` | `string` | — | Who: the viewer, the raiding channel, the destination. | | `short` | `string` | — | "Resub · 14 months". | | `line` | `string` | — | The sentence, on hover and for Copy. | | `message` | `string` | — | The viewer's own words. | | `time` | `string` | — | "now", "2m", "1h". | | `at` | `string` | — | ISO, for the time element. | | `messageId` | `string` | — | The chat message Show on stream puts on stream. | | `authorAvatarUrl` | `string` | — | — | | `gift` | `boolean` | — | A gifted sub or membership: the gift glyph. | | `unnamed` | `boolean` | — | A count the platform never named. | | `highlight` | `ChatMessageHighlight` | — | Its place on stream. | `ACTIVITY_FILTERS`, `activityFilterFor`, `filterActivity`, `activityFilterCounts`, `activityRowShowsPerson`, `activityRowTint` and `activityRowActions` are the rules the pane follows. # Chat Composer (/docs/blocks/chat-composer) ```tsx title="components/examples/chat-composer-demo.tsx" "use client"; import { useEffect, useState } from "react"; import { ChatComposer } from "@/components/blocks/stream-manager/chat-composer"; import type { ChatComposerCommand } from "@/components/blocks/stream-manager/chat-composer"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; const DESTINATIONS: ChatDestination[] = [ { accountLabel: "Night Owl Studio", id: "yt", platform: "youtube", state: "connected", write: "ready", }, { accountLabel: "nightowlstudio", id: "tw", platform: "twitch", state: "connected", write: "ready", }, { id: "x", platform: "x", state: "connected", write: "read-only" }, ]; const COMMANDS: ChatComposerCommand[] = [ { argument: true, hint: " [title] · Save a timeline point", value: "/marker", }, { hint: " · Save the last 30 seconds as a clip", value: "/clip" }, { hint: " · Save the last minute as a clip", value: "/clip 60" }, { hint: " · Command help", value: "/help" }, ]; const SEND_TIME_MS = 900; const ChatComposerDemo = () => { const [pending, setPending] = useState(false); const [notice, setNotice] = useState(null); useEffect(() => { if (!pending) { return; } const timer = setTimeout(() => setPending(false), SEND_TIME_MS); return () => clearTimeout(timer); }, [pending]); return (
setNotice( command.startsWith("/marker") ? `Marker saved at 30:44 · ${command.slice(8).trim() || "Untitled marker"}` : "Saving the last 30 seconds as a clip…" ) } onSend={() => { setNotice(null); setPending(true); }} pending={pending} placeholder="Message, /marker [title] or /clip…" />
); }; export default ChatComposerDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/chat-composer ``` ## Usage ```tsx import { ChatComposer } from "@/components/blocks/stream-manager/chat-composer"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; ``` ```tsx const destinations: ChatDestination[] = [ { id: "yt", platform: "youtube", state: "connected", write: "ready" }, { id: "tw", platform: "twitch", state: "connected", write: "ready" }, { id: "x", platform: "x", state: "connected", write: "read-only" }, ]; send(text, destinationIds)} pending={sending} />; ``` A message goes to every destination whose `write` is `ready`. With more than one, the "To:" picker lets the streamer narrow a send; `onSend` then gets the picked `destinationIds`, and without a pick it gets none. The draft must fit the strictest destination it reaches: 200 characters, 140 with X. The line under the field speaks only for the exceptions: a destination the send skips ("X read-only"), a failed send's reason with Retry, and "Sending…" while a send is on its way. A finished send says nothing: the message shows up in chat. ## Examples ### Local commands Pass `commands` and the field completes them after "/": ↑ and ↓ move, Tab or ↵ completes. A command runs through `onCommand` and never posts to chat; "//" sends one literal slash. `notice` is the composer's answer, read politely: "Marker saved at 30:44 · Untitled marker". The demo above wires Videorc's `/marker` and `/clip`. ### A failed send `failures` names each destination a send didn't reach, with its reason; `onRetry` adds Retry. YouTube's chip names it in words: its mark may not shrink to a chip's 12 px. ```tsx title="components/examples/chat-composer-failure.tsx" "use client"; import { useState } from "react"; import { ChatComposer } from "@/components/blocks/stream-manager/chat-composer"; import type { ChatSendFailure } from "@/components/blocks/stream-manager/chat-composer"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; const DESTINATIONS: ChatDestination[] = [ { id: "yt", platform: "youtube", state: "connected", write: "ready" }, { id: "tw", platform: "twitch", state: "connected", write: "ready" }, { id: "x", platform: "x", state: "connected", write: "read-only" }, ]; const FAILURES: ChatSendFailure[] = [ { destinationId: "tw", platform: "twitch", reason: "Twitch rejected this message: slow mode is on.", }, ]; const ChatComposerFailure = () => { const [failures, setFailures] = useState(FAILURES); return (
setFailures([])} onSend={() => setFailures([])} />
); }; export default ChatComposerFailure; ``` ### The window owns the draft Pass `value` and `onValueChange` when something else fills the field, such as a row's ⋯ Reply that writes "@Maya Chen ", and `inputRef` for ⌘L to focus it. ## Theming | Token or attribute | Meaning | | --- | --- | | `border-t` `px-2 py-2` | The flush region under a hairline, never a card | | `InputGroup` | The field, its picker, count, key and send | | `text-label` | The count and the notes line | | `Badge` `destructive` | A failed destination | | `data-slot` | `chat-composer`, `chat-composer-count`, `chat-composer-status` | ## Keyboard | Key | Action | | ------------------------- | ------------------------------------ | | Enter | Sends, or runs a local command | | ↑ ↓ | Move through the command completions | | Tab | Completes the chosen command | | Esc | Closes the completions | ## Accessibility The field is named "Message", or "Message or local command" with commands. The send button's name says where it sends: "Send message to all writable destinations", "Send message to Twitch", or why it can't. The picker is named "Send to: All". `notice` is a polite status, and an over-limit count carries the alert icon as well as its number. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `destinations` | `readonly ChatDestination[]` | — | — | | `onSend` | `(text: string, options: { destinationIds?: string[] }) => void` | — | — | | `commands` | `readonly ChatComposerCommand[]` | — | Local commands to complete after "/". | | `onCommand` | `(text: string) => void` | — | Runs a local command. | | `pending` | `boolean` | `false` | A send is on its way: Sending… | | `failures` | `readonly ChatSendFailure[]` | `[]` | — | | `onRetry` | `() => void` | — | Adds Retry under a failed send. | | `notice` | `ReactNode` | — | The composer's own answer. | | `value` | `string` | — | The draft, when the window owns it. | | `defaultValue` | `string` | `""` | — | | `onValueChange` | `(value: string) => void` | — | — | | `placeholder` | `string` | `"Send a message…"` | — | | `inputRef` | `RefObject` | — | For ⌘L. | | `platform` | `string` | — | darwin, win32, linux…: how ↵ is printed. | ### ChatComposerCommand | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `string` | — | "/marker" | | `hint` | `string` | — | " [title] · Save a timeline point" | | `argument` | `boolean` | — | It takes an argument: completing it leaves a space. | ### ChatSendFailure | Prop | Type | Default | Description | | --- | --- | --- | --- | | `destinationId` | `string` | — | — | | `platform` | `ChatPlatform` | — | — | | `reason` | `string` | — | The destination's reason, in a sentence. | `chatDestinationNotes(destinations, sendPlatforms)` and `chatDraftMaxChars(platforms)` are the rules the composer follows, for a status of your own. # Chat Message (/docs/blocks/chat-message) ```tsx title="components/examples/chat-message-demo.tsx" "use client"; import { useState } from "react"; import { ChatMessage } from "@/components/blocks/stream-manager/chat-message"; import type { ChatMessageData } from "@/components/blocks/stream-manager/chat-message"; // While live the rows keep their time out of sight until the pointer comes: // these carry none at all, so the words have the room. const MESSAGES: ChatMessageData[] = [ { authorName: "Maya Chen", id: "m1", platform: "youtube", text: "Which microphone are you using on stream today?", }, { authorName: "jonas", authorRoles: ["member"], id: "m2", platform: "twitch", text: "The new two-camera scene looks so clean.", }, { authorName: "priya", id: "m3", platform: "x", text: "Watching from Lisbon. Audio is perfectly in sync!", }, ]; const ChatMessageDemo = () => { const [onStream, setOnStream] = useState("m3"); return (
    {MESSAGES.map((message) => ( setOnStream((current) => (current === target.id ? null : target.id)) } onReply={() => null} /> ))}
); }; export default ChatMessageDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/chat-message ``` ## Usage ```tsx import { ChatMessage } from "@/components/blocks/stream-manager/chat-message"; import type { ChatMessageData } from "@/components/blocks/stream-manager/chat-message"; ``` ```tsx const message: ChatMessageData = { id: "m1", platform: "youtube", authorName: "Maya Chen", text: "Which microphone are you using on stream today?", time: "8:41 PM", };
; ``` A message is plain data. `ChatMessage` renders one `
  • `, so put the rows in an `
      `. With `onHighlight` the row is one button: a click puts the message on stream, and a click on the row that's on stream takes it off. Without it the row is plain text, as in History. Format `time` yourself, once, so the server and the browser agree: `formatChatTime(receivedAt)` gives Videorc's "8:42 PM". ## Examples ### Every state The row on stream wears the live chrome plate. A member's row is the quieter plate with a member-hue edge, a paid message is the warning fill with a hairline ring, and Buddy's "Talking about this" is the flat accent. A row paints one of them at most, and the row on stream beats them all. Chips sit after the name: roles, first chat, `@you` for a mention, the amount, Buddy's mark, the stream state and the removal. A failed show and an unusual removal say why on the row's own line. ```tsx title="components/examples/chat-message-states.tsx" "use client"; import { ChatMessage } from "@/components/blocks/stream-manager/chat-message"; import type { ChatMessageProps } from "@/components/blocks/stream-manager/chat-message"; type Row = Omit & { highlightable?: boolean }; const ROWS: Row[] = [ { highlight: { phase: "live" }, message: { authorName: "Priya Raman", id: "on-stream", platform: "x", text: "On stream: the chrome plate with its polished edge.", time: "8:40 PM", }, }, { highlight: { phase: "applying" }, message: { authorName: "Leo Park", id: "applying", platform: "youtube", text: "Applying: the row waits while Studio puts it on stream.", time: "8:40 PM", }, }, { highlight: { phase: "failed", reason: "Studio didn't answer. Try again.", }, message: { authorName: "Sam Okafor", id: "failed", platform: "twitch", text: "Failed: the reason is the row's own line.", time: "8:41 PM", }, }, { message: { authorName: "nightowl_jonas", authorRoles: ["member"], id: "member", platform: "twitch", text: "A member's message wears the quieter plate with a member-hue edge.", time: "8:41 PM", }, }, { message: { amountText: "$5.00", authorName: "Maya Chen", id: "paid", kind: "paid", platform: "youtube", text: "A Super Chat: the warning fill and its hairline ring.", time: "8:41 PM", }, }, { mentionNames: ["@nightowlstudio"], message: { authorName: "kai.streams", firstMessage: true, id: "mention", platform: "twitch", text: "@nightowlstudio first time here, is this the two-camera setup?", time: "8:42 PM", }, }, { message: { authorName: "Ana Silva", authorRoles: ["moderator"], id: "reply", platform: "youtube", reply: { authorName: "Maya Chen", text: "Which microphone are you using?", }, text: "It's the Shure MV7, he said it last week.", time: "8:42 PM", }, }, { message: { authorName: "Leo Park", id: "spotlight", platform: "youtube", text: "Buddy heard you talking about this one.", time: "8:43 PM", }, spotlight: true, }, { message: { authorName: "Hana Ito", id: "suggested", platform: "x", text: "This stream got me to finally set up my own studio.", time: "8:43 PM", }, suggested: true, }, { flag: { action: "Remove?", detail: "Buddy flagged this as spam: a link repeated in chat.", label: "Spam", severity: "high", }, message: { authorName: "promo_bot_77", id: "flagged", platform: "twitch", text: "free followers at a site you've never heard of", time: "8:43 PM", }, }, { message: { authorName: "promo_bot_12", id: "removing", platform: "twitch", text: "free followers, again", time: "8:44 PM", }, removal: { kind: "removing" }, }, { highlightable: false, message: { authorName: "promo_bot_77", deleted: true, id: "removed", platform: "twitch", text: "free followers at a site you've never heard of", time: "8:44 PM", }, removal: { detail: "Removed by you on Twitch.", kind: "removed" }, }, { highlightable: false, message: { authorName: "Twitch", id: "moderation", kind: "moderation", platform: "twitch", text: "promo_bot_77 was timed out for 10 minutes.", time: "8:44 PM", }, }, ]; const noop = () => null; const ChatMessageStates = () => (
        {ROWS.map(({ highlightable = true, ...row }) => ( ))}
      ); export default ChatMessageStates; ``` ### Compact `density="compact"` sets 12 px text and a 32 px avatar, for a narrow rail beside the Studio. The Stream Manager keeps the 15 px rows. ```tsx title="components/examples/chat-message-compact.tsx" "use client"; import { ChatMessage } from "@/components/blocks/stream-manager/chat-message"; import type { ChatMessageData } from "@/components/blocks/stream-manager/chat-message"; const MESSAGES: ChatMessageData[] = [ { authorName: "Maya Chen", id: "c1", platform: "youtube", text: "Which microphone are you using on stream today?", time: "8:41 PM", }, { authorName: "nightowl_jonas", authorRoles: ["member"], id: "c2", platform: "twitch", text: "The new two-camera scene looks so clean.", time: "8:42 PM", }, { authorName: "Priya Raman", id: "c3", platform: "x", text: "Watching from Lisbon.", time: "8:42 PM", }, ]; const ChatMessageCompact = () => (
        {MESSAGES.map((message) => ( ))}
      ); export default ChatMessageCompact; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `chrome-row-live` | The row on stream (`CHROME_ROW_CLASS.live`) | | `chrome-row-member` | A member's row (`CHROME_ROW_CLASS.member`) | | `bg-warning/10 ring-warning/30` | A paid message | | `bg-accent` | The message the streamer is talking about | | `Badge` `chrome`, `destructive`, `warning`, `plate` | On stream, Failed, the amount, Member | | `group/chat-message`, `group/kebab-host` | The row the time and the ⋯ wait for | | `data-highlight-phase` | `idle`, `applying`, `live` or `failed` | | `data-row-tint` | `paid`, `spotlight` or `member` | | `data-mention`, `data-member` | Set on a mention and on a member's row | | `data-slot` | `chat-message`, `chat-message-author`, `chat-message-text`, `chat-message-reply`, `chat-message-problem`, `chat-message-outcome` | ## Keyboard | Key | Action | | --- | --- | | Enter Space | Puts the message on stream, or takes it off | | Tab | Moves to the row's ⋯ menu | ## Accessibility A highlightable row is one button named by what it says: author, platform and words ("Maya Chen on YouTube: Which microphone…"). `aria-pressed` says it's on stream and the description says what a click does, then any outcome the row shows. A drag that selects words in the row copies them and never puts the message on stream. The time and the ⋯ wait for the pointer while live (`timestamps="hover"`) and appear with keyboard focus. Every chip that stands for more says it in a tooltip, never a native `title`. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `message` | `ChatMessageData` | — | — | | `density` | `"comfortable" \| "compact"` | `"comfortable"` | 15 px rows with a 40 px avatar, or 12 px with 32 px. | | `timestamps` | `"always" \| "hover"` | `"always"` | hover: the time waits for the pointer, as while live. | | `highlight` | `ChatMessageHighlight` | `{ phase: "idle" }` | — | | `removal` | `ChatMessageRemoval \| null` | — | — | | `flag` | `ChatMessageFlag` | — | What Buddy flagged on it. | | `suggested` | `boolean` | `false` | Buddy suggests showing it. Shown only on an idle, highlightable row. | | `spotlight` | `boolean` | `false` | The streamer is talking about it: Talking about this. | | `mentionNames` | `readonly string[]` | `[]` | The streamer's own accounts: a message naming one shows @you. | | `onHighlight` | `(message) => void` | — | Makes the row a button that puts the message on stream or takes it off. | | `onReply` | `(message) => void` | — | Adds Reply to the ⋯ menu. | | `onCopy` | `(message) => void` | — | Copy's action. Copies "Author: text" to the clipboard when absent. | | `onRemoveFromChat` | `(message) => void` | — | Adds Remove from chat, destructive and last. | | `removeFromChatReason` | `string \| null` | — | Keeps Remove from chat, disabled, with this reason on hover. | | `tabIndex` | `number` | `0` | -1 on every row but the active one in a roving-focus list. | | `ref` | `Ref` | — | For a virtualized list to measure it. | | `style` | `CSSProperties` | — | For a virtualized list to place it. | ### ChatMessageData | Prop | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | — | — | | `platform` | `ChatPlatform` | — | Where it came from. | | `authorName` | `string` | — | — | | `authorAvatarUrl` | `string` | — | Initials show until it loads, and without one. | | `authorRoles` | `("owner" \| "moderator" \| "vip" \| "member")[]` | — | Host, Mod, VIP and Member chips. A member's row wears the member plate. | | `text` | `string` | — | — | | `kind` | `"message" \| "paid" \| "membership" \| "system" \| "moderation"` | `"message"` | System and moderation lines are italic and never go on stream. | | `amountText` | `string` | — | A paid message's amount: "$5.00". | | `firstMessage` | `boolean` | — | First chat. | | `reply` | `{ authorName: string; text: string }` | — | The message it answers, above its own words. | | `deleted` | `boolean` | — | Struck through, never on stream. | | `time` | `string` | — | The time as shown: "8:42 PM". | | `receivedAt` | `string` | — | ISO, for the time element. | ### ChatMessageHighlight | Prop | Type | Default | Description | | --- | --- | --- | --- | | `phase` | `"idle" \| "applying" \| "live" \| "failed"` | — | — | | `reason` | `string` | — | Why a show failed, on the row's own line. | | `commandError` | `string` | — | A failed action on a message already on stream. | ### ChatMessageRemoval | Prop | Type | Default | Description | | --- | --- | --- | --- | | `kind` | `"removing" \| "removed" \| "hidden" \| "failed" \| "unknown"` | — | Removing…, Removed, Hidden in Videorc, Not removed, Unconfirmed. | | `detail` | `string \| null` | — | The outcome: on hover for a plain removal, the row's own line otherwise. | ### ChatMessageFlag | Prop | Type | Default | Description | | --- | --- | --- | --- | | `label` | `string` | — | The chip: "Spam". | | `detail` | `string` | — | Why Buddy flagged it, on hover. | | `severity` | `"high" \| "low"` | — | high tints the chip. | | `action` | `string` | — | A suggestion after the chip: "Remove?". | ### Helpers `HighlightStatus` and `RemovalStatus` are the row's two status chips, for rows of your own that share the slot (the [Activity Feed](/docs/blocks/activity-feed) does). `chatMessageMenu(message, options)` builds the ⋯ items; `chatMessageMentions(message, names)`, `chatMessageCanHighlight(message)`, `chatMessageInitials(name)` and `formatChatTime(iso)` are the rules the row follows. # Chat Platform (/docs/blocks/chat-platform) ```tsx title="components/examples/chat-platform-demo.tsx" "use client"; import { CHAT_PLATFORM_LABELS, ChatPlatformIcon, } from "@/components/blocks/stream-manager/chat-platform"; import type { ChatPlatform } from "@/components/blocks/stream-manager/chat-platform"; const PLATFORMS: ChatPlatform[] = [ "youtube", "twitch", "x", "tiktok", "instagram", "facebook", "custom", ]; const ChatPlatformDemo = () => (
        {PLATFORMS.map((platform) => (
      • {CHAT_PLATFORM_LABELS[platform]}
      • ))}
      ); export default ChatPlatformDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/chat-platform ``` ## Usage ```tsx import { CHAT_PLATFORM_LABELS, ChatPlatformIcon, chatStateLabel, } from "@/components/blocks/stream-manager/chat-platform"; ``` ```tsx ``` Every Stream Manager part names a platform through it: a message's mark, the filter chips, the stats' hover cards and the status bar's chat states. Pass `decorative` when the words beside the mark already name the platform. ## Examples ### Chat states `chatStateLabel(destination)` turns a destination's connection into Videorc's chat vocabulary: "Connected", "Read-only", "Reconnect to send", "Reconnecting…". It never says "receive-only", and never shows a raw value. ```tsx title="components/examples/chat-platform-states.tsx" "use client"; import { CHAT_PLATFORM_LABELS, ChatPlatformIcon, chatStateLabel, } from "@/components/blocks/stream-manager/chat-platform"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; const DESTINATIONS: ChatDestination[] = [ { id: "yt", platform: "youtube", state: "connected", write: "ready" }, { id: "tw", platform: "twitch", state: "connected", write: "missing-scope", }, { id: "x", platform: "x", state: "connected", write: "read-only" }, { id: "tt", platform: "tiktok", state: "reconnecting" }, ]; const ChatPlatformStates = () => (
        {DESTINATIONS.map((destination) => (
      • {CHAT_PLATFORM_LABELS[destination.platform]} {chatStateLabel(destination)}
      • ))}
      ); export default ChatPlatformStates; ``` ## The marks Platform marks are the one place saturated colour is allowed in the chrome. Each mark is 20 px wherever it appears: YouTube's icon may not go below 20 px, so the others match it, and YouTube keeps clear space after it. A surface with no room for 20 px, such as a [Badge](/docs/components/badge), leaves the mark out and keeps the platform's name. The glyphs come from [Icons](/docs/concepts/icons) (`YoutubeIcon`, `TwitchIcon`, `XPlatformIcon`…), so swapping one there swaps it here. ## Theming | Token or attribute | Meaning | | --- | --- | | `text-platform-youtube`, `text-platform-twitch-ink`, `text-platform-facebook`, `text-platform-instagram` | The brand tints | | `text-foreground` | X and TikTok: black or white by theme | | `size-5` | The 20 px mark | | `data-platform` | The platform the mark stands for | | `data-slot="chat-platform-icon"` | The mark | ## Accessibility A mark is an image named by its platform. With `decorative` it's hidden from assistive tech, so the platform is named once. ## API reference ### ChatPlatformIcon | Prop | Type | Default | Description | | --- | --- | --- | --- | | `platform` | `"youtube" \| "twitch" \| "x" \| "facebook" \| "tiktok" \| "instagram" \| "custom"` | — | — | | `decorative` | `boolean` | `false` | Hidden from assistive tech, for a mark beside its platform's name. | | `className` | `string` | — | Spacing; the size stays 20 px. | ### ChatDestination | Prop | Type | Default | Description | | --- | --- | --- | --- | | `id` | `string` | — | — | | `platform` | `ChatPlatform` | — | — | | `state` | `"connected" \| "connecting" \| "reconnecting" \| "waiting" \| "paused" \| "failed" \| "ended" \| "off" \| "unavailable"` | — | The chat connection. | | `write` | `"ready" \| "read-only" \| "missing-scope" \| "failed"` | `"read-only"` | Whether Videorc can send to it. | | `accountLabel` | `string` | — | The account chat is read as. | | `message` | `string` | — | The destination's own words when chat is stuck. | `CHAT_PLATFORM_LABELS` maps each platform to its one name ("X", never "X / Twitter"; "Custom RTMP", never "Custom"). # Chat Toolbar (/docs/blocks/chat-toolbar) ```tsx title="components/examples/chat-toolbar-demo.tsx" "use client"; import { useState } from "react"; import { ChatMessage } from "@/components/blocks/stream-manager/chat-message"; import type { ChatMessageData } from "@/components/blocks/stream-manager/chat-message"; import { ChatToolbar, DEFAULT_CHAT_FILTER, filterChatMessages, } from "@/components/blocks/stream-manager/chat-toolbar"; import type { ChatFilter } from "@/components/blocks/stream-manager/chat-toolbar"; const MESSAGES: ChatMessageData[] = [ { authorName: "Maya Chen", id: "t1", platform: "youtube", text: "Which microphone are you using on stream today?", time: "8:41 PM", }, { authorName: "nightowl_jonas", authorRoles: ["member"], id: "t2", platform: "twitch", text: "@nightowlstudio the new two-camera scene looks so clean.", time: "8:42 PM", }, { authorName: "Priya Raman", id: "t3", platform: "x", text: "Watching from Lisbon. Audio is perfectly in sync!", time: "8:42 PM", }, ]; /** Buddy's open question is about Maya's message. */ const QUESTION_IDS = new Set(["t1"]); const MENTION_NAMES = ["@nightowlstudio"]; const ChatToolbarDemo = () => { const [filter, setFilter] = useState(DEFAULT_CHAT_FILTER); const shown = filterChatMessages(MESSAGES, filter, { mentionNames: MENTION_NAMES, questionIds: QUESTION_IDS, }); return (
        {shown.map((message) => ( ))} {shown.length === 0 ? (
      1. No messages match these filters.
      2. ) : null}
      ); }; export default ChatToolbarDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/chat-toolbar ``` ## Usage ```tsx import { ChatToolbar, DEFAULT_CHAT_FILTER, filterChatMessages, } from "@/components/blocks/stream-manager/chat-toolbar"; import type { ChatFilter } from "@/components/blocks/stream-manager/chat-toolbar"; ``` ```tsx const [filter, setFilter] = useState(DEFAULT_CHAT_FILTER); const shown = filterChatMessages(messages, filter, { mentionNames: ["@nightowlstudio"], questionIds, }); 0} />; ``` The filter is plain data you own: ```ts interface ChatFilter { platform: ChatPlatform | "all"; questions: boolean; mentions: boolean; search: string; } ``` `filterChatMessages(messages, filter, { questionIds, mentionNames })` applies it: one platform, the messages behind Buddy's open questions, the ones that mention your accounts, and a search through words and names. `chatFilterActive(filter)` says whether anything narrows chat, for an empty state that reads "No messages match these filters." The Questions chip shows while Buddy has open questions (`questions`), and stays while it's on. The search field takes ⌘F's focus through `searchRef`; Esc clears it. ## Responsive Inside the [Stream Manager](/docs/blocks/stream-manager), the strip follows the window's own width: from 640 px the filters are inline chips, and below it they fold into one Filters menu with a count of what's on. On its own, outside the Stream Manager's container, the chips always show. ## Theming | Token or attribute | Meaning | | --- | --- | | `border-b` `px-2 py-1.5` | The strip, flush under a hairline | | `ToggleGroup` | The filter chips; an active one is the chrome plate | | `@max-[639px]/stream-manager:` | Where the chips fold into the Filters menu | | `data-slot` | `chat-toolbar`, `chat-search` | ## Keyboard | Key | Action | | ------------------------- | ------------------------------------------------ | | ⌘ F | Searches chat, when the window wires `searchRef` | | Esc | In the search field, clears it and leaves | ## Accessibility The platform chips are a group named "Show chat from", and Questions and Mentions a group named "Chat filters", each explained on hover. The search field is named "Search chat". Below 640 px the Filters button is named "Chat filters" and its menu holds the same choices as radio and checkbox items. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `platforms` | `readonly ChatPlatform[]` | — | The platforms chat comes from: a chip each, from two up. | | `filter` | `ChatFilter` | — | — | | `onFilterChange` | `(filter: ChatFilter) => void` | — | — | | `questions` | `boolean` | `false` | Buddy has open questions: the Questions chip shows. | | `searchRef` | `Ref` | — | The search field, for ⌘F. | | `platform` | `string` | — | darwin, win32, linux…: how ⌘F is printed. | # Blocks (/docs/blocks) Blocks are whole pieces of Videorc's screens, composed from the [components](/docs/components) and [primitives](/docs/primitives) the way Videorc composes them. They're presentational: plain data and callbacks go in, and nothing reaches for Electron, IPC or `window.videorc`. Your app owns the state and the side effects. ## Stream Manager Videorc's live dashboard window: the stats bar, chat beside Activity and Buddy, and the status bar. [Stream Manager](/docs/blocks/stream-manager) is the whole window; each part installs on its own too. - [Chat Message](/docs/blocks/chat-message): one message, the big-text row. - [Chat Toolbar](/docs/blocks/chat-toolbar): platform chips, Questions, Mentions and search. - [Chat Composer](/docs/blocks/chat-composer): the "To:" picker, the message field and send. - [Stream Stats Bar](/docs/blocks/stream-stats-bar): the ON AIR clock, viewers, followers and the rest. - [Activity Feed](/docs/blocks/activity-feed): follows, subs, gifts, tips and raids. - [Stream Manager Status Bar](/docs/blocks/stream-manager-status-bar): each platform's chat state and the window's actions. - [Platform Filter](/docs/blocks/platform-filter) and [Chat Platform](/docs/blocks/chat-platform): the pieces the parts share. Every block sizes itself to its container, never the viewport, so it works in a window, a split pane or a docs preview alike. # Platform Filter (/docs/blocks/platform-filter) ```tsx title="components/examples/platform-filter-demo.tsx" "use client"; import { useState } from "react"; import { CHAT_PLATFORM_LABELS } from "@/components/blocks/stream-manager/chat-platform"; import { PlatformFilter } from "@/components/blocks/stream-manager/platform-filter"; import type { PlatformFilterValue } from "@/components/blocks/stream-manager/platform-filter"; const PlatformFilterDemo = () => { const [platform, setPlatform] = useState("all"); return (

      {platform === "all" ? "Chat from every platform." : `Only ${CHAT_PLATFORM_LABELS[platform]} chat.`}

      ); }; export default PlatformFilterDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/platform-filter ``` ## Usage ```tsx import { PlatformFilter, PlatformFilterMenuItems, } from "@/components/blocks/stream-manager/platform-filter"; ``` ```tsx ``` Chat and Activity share it, so a platform filters the same way in both. A chip clicked again turns back to All. With one platform it renders nothing: there's nothing to filter by. Below 640 px of Stream Manager the chips move into the pane's Filters menu: put `PlatformFilterMenuItems` inside a [Dropdown Menu](/docs/components/dropdown-menu), with the same props. [Chat Toolbar](/docs/blocks/chat-toolbar) does both. ## Theming | Token or attribute | Meaning | | ------------------ | --------------------------------------------- | | `ToggleGroup` | The chips; the chosen one is the chrome plate | | `ChatPlatformIcon` | Each platform's 20 px mark | ## Accessibility The group is named by `label` ("Show chat from"). Each icon chip is named by its platform and says "Only YouTube" on hover. In the menu, the choice is a radio group under the same label, with "Every platform" first. ## API reference ### PlatformFilter | Prop | Type | Default | Description | | --- | --- | --- | --- | | `platforms` | `readonly ChatPlatform[]` | — | A chip each, in this order. Fewer than two renders nothing. | | `value` | `ChatPlatform \| "all"` | — | — | | `onValueChange` | `(value: ChatPlatform \| "all") => void` | — | — | | `label` | `string` | — | The group's name: "Show chat from", "Show activity from". | ### PlatformFilterMenuItems The same props, as a labelled radio group for a Filters menu. # Stream Manager Status Bar (/docs/blocks/stream-manager-status-bar) ```tsx title="components/examples/stream-manager-status-bar-demo.tsx" "use client"; import { useState } from "react"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; import { StreamManagerStatusBar } from "@/components/blocks/stream-manager/stream-manager-status-bar"; import type { HighlightPosition } from "@/components/blocks/stream-manager/stream-manager-status-bar"; const DESTINATIONS: ChatDestination[] = [ { accountLabel: "Night Owl Studio", id: "yt", platform: "youtube", state: "connected", write: "ready", }, { accountLabel: "nightowlstudio", id: "tw", platform: "twitch", state: "connected", write: "ready", }, { accountLabel: "@nightowlstudio", id: "x", platform: "x", state: "connected", write: "read-only", }, ]; const StreamManagerStatusBarDemo = () => { const [pinned, setPinned] = useState(true); const [position, setPosition] = useState("bottom-left"); const [screenHidden, setScreenHidden] = useState(false); const [said, setSaid] = useState("Every control says what it does on hover."); return ( // The Stream Manager's container: the bar folds by its width.

      {said}

      setSaid("Chat and Activity were cleared.")} onFocusBuddy={() => setSaid("Buddy's pane comes forward.")} onHighlightPositionChange={setPosition} onMarkClip={(seconds) => setSaid( seconds === 30 ? "Saving the last 30 seconds as a clip…" : "Saving the last minute as a clip…" ) } onOpenPreview={() => setSaid("The Preview window opens.")} onPinnedChange={setPinned} onScreenPrivacy={(hidden) => { setScreenHidden(hidden); setSaid( hidden ? "Your screen is hidden from viewers." : "Your screen shows again." ); }} onSearchChat={() => setSaid("Chat's search takes the focus.")} onWriteInChat={() => setSaid("The composer takes the focus.")} pinned={pinned} screenHidden={screenHidden} />
      ); }; export default StreamManagerStatusBarDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/stream-manager-status-bar ``` ## Usage ```tsx import { StreamManagerStatusBar } from "@/components/blocks/stream-manager/stream-manager-status-bar"; ``` ```tsx saveClip(seconds)} onOpenPreview={openPreview} onPinnedChange={setPinned} onSearchChat={focusSearch} onWriteInChat={focusComposer} pinned={pinned} /> ``` The left side is chat, per destination: its 20 px mark and a status dot. Quiet when fine: a platform that reads and sends says nothing more, and a word appears only when something needs the streamer ("X read-only", "Twitch reconnect to send"), or the destination's own words when chat is stuck. The details open on hover and on focus: what chat can do there, the account it reads as, and any `destinationNotes`. The right side is the window's controls, each one there only when you pass its callback, and every one labelled: - the window's keys as clickable hints: ⌘F Search, ⌘L Write and ⌘J Buddy; - Hide screen, Keep on top ([Pin Window Button](/docs/primitives/pin-window-button)) and Highlight position (a menu); - Clip 30 s and 1 min, with the clip icon and words: mid-stream there's no time to hover; - Banners (a menu of checks), Clear (it asks first, because it can't be undone) and Open Preview. ## Examples ### Narrow Below 800 px of Stream Manager the hints keep their keys and the words go to screen readers. Below 640 px every control folds into one ⋯ menu, "More Stream Manager actions", with the stats bar's show, hide and reset choices; a dot that isn't green keeps its word beside it. ```tsx title="components/examples/stream-manager-status-bar-narrow.tsx" "use client"; import { useState } from "react"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; import { StreamManagerStatusBar } from "@/components/blocks/stream-manager/stream-manager-status-bar"; import { DEFAULT_STATS_LAYOUT } from "@/components/blocks/stream-manager/stream-stats-bar"; import type { StatsLayout } from "@/components/blocks/stream-manager/stream-stats-bar"; const DESTINATIONS: ChatDestination[] = [ { id: "yt", platform: "youtube", state: "connected", write: "ready" }, { id: "tw", message: "Twitch chat is reconnecting. Messages wait until it's back.", platform: "twitch", state: "reconnecting", write: "ready", }, { id: "x", platform: "x", state: "connected", write: "read-only" }, ]; const StreamManagerStatusBarNarrow = () => { const [pinned, setPinned] = useState(false); const [layout, setLayout] = useState(DEFAULT_STATS_LAYOUT); return (
      null} onMarkClip={() => null} onOpenPreview={() => null} onPinnedChange={setPinned} onSearchChat={() => null} onStatsLayoutChange={setLayout} onWriteInChat={() => null} pinned={pinned} statsLayout={layout} />
      ); }; export default StreamManagerStatusBarNarrow; ``` ## Responsive The bar folds by the width of the `@container/stream-manager` around it, never the viewport: from 1,040 px Hide screen shows its word, from 800 px the hints show theirs, and below 640 px the ⋯ menu takes over. The examples wrap it in that container; the [Stream Manager](/docs/blocks/stream-manager) is one. On its own, outside the container, every control shows. ## Theming | Token or attribute | Meaning | | --- | --- | | `h-8` | 32 px, not the shared 26: the 20 px marks need room | | `StatusDot` | Each destination's tone: good, warn, error | | `StatusBarHint`, `StatusBarAction` | The hints and the labelled actions | | `@max-[639px]/stream-manager:` | Where the controls fold into the ⋯ menu | | `data-slot` | `chat-states`, `chat-state`, `stream-manager-controls`, `stream-manager-actions` | ## Keyboard | Key | Action | | --- | --- | | Tab | Moves through the chat states (each opens its details on focus) and the controls | | Enter Space | Runs a control, or opens its menu | The hints print the window's keys; the [Stream Manager](/docs/blocks/stream-manager) answers them. ## Accessibility Each chat state is a button named by its first line ("X chat: read-only"). Every action has a name and a tooltip, never a native `title`; an icon alone is named by its tooltip. Hide screen says why when it can't run, through [Disabled Reason](/docs/components/disabled-reason). Clear asks in a [Confirm Dialog](/docs/primitives/confirm-dialog) first. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `destinations` | `readonly ChatDestination[]` | — | — | | `destinationNotes` | `Partial>` | — | More lines for a platform's hover card. | | `onSearchChat` | `() => void` | — | ⌘F Search. | | `onWriteInChat` | `() => void` | — | ⌘L Write. | | `onFocusBuddy` | `() => void` | — | ⌘J Buddy. | | `pinned` | `boolean` | `false` | — | | `onPinnedChange` | `(pinned: boolean) => void` | — | Keep on top. | | `highlightPosition` | `"top-left" \| "top-right" \| "bottom-left" \| "bottom-right"` | — | — | | `onHighlightPositionChange` | `(position) => void` | — | — | | `onMarkClip` | `(seconds: 30 \| 60) => void` | — | The two Clip buttons. | | `screenHidden` | `boolean` | `false` | — | | `onScreenPrivacy` | `(hidden: boolean) => void` | — | Hide screen, or Show screen. | | `screenHideDisabledReason` | `string \| null` | — | Why a hide can't run now. Showing again always runs. | | `banners` | `readonly { id: string; name: string; shown: boolean }[] \| null` | — | null until the list loads. | | `onBanner` | `(action: BannerAction) => void` | — | — | | `onClear` | `() => void` | — | Clear view, after it asks. | | `onOpenPreview` | `() => void` | — | — | | `statsLayout` | `StatsLayout` | — | For the ⋯ menu's Show or hide stats. | | `onStatsLayoutChange` | `(layout: StatsLayout) => void` | — | — | | `platform` | `string` | — | darwin, win32, linux…: how the keys are printed. | # Stream Manager (/docs/blocks/stream-manager) ```tsx title="components/examples/stream-manager-demo.tsx" "use client"; import { useEffect, useState } from "react"; import type { ActivityItem } from "@/components/blocks/stream-manager/activity-feed"; import type { ChatComposerCommand } from "@/components/blocks/stream-manager/chat-composer"; import type { ChatMessageData } from "@/components/blocks/stream-manager/chat-message"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; import { StreamManager } from "@/components/blocks/stream-manager/stream-manager"; import type { StreamHighlight } from "@/components/blocks/stream-manager/stream-manager"; import type { HighlightPosition } from "@/components/blocks/stream-manager/stream-manager-status-bar"; import { DEFAULT_STATS_LAYOUT } from "@/components/blocks/stream-manager/stream-stats-bar"; import type { StatItem, StatsLayout, } from "@/components/blocks/stream-manager/stream-stats-bar"; import { SparkleIcon } from "@/components/icons"; import { StatusDot } from "@/components/status-dot"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; const STARTED_SECONDS = 30 * 60 + 44; const SECONDS_PER_MINUTE = 60; const TICK_MS = 1000; const ARRIVAL_MS = 6000; const APPLY_MS = 500; const SEND_MS = 700; const MOST_MESSAGES = 40; const clock = (seconds: number): string => `${Math.floor(seconds / SECONDS_PER_MINUTE)}:${String(seconds % SECONDS_PER_MINUTE).padStart(2, "0")}`; const statItems = (seconds: number, chatPace: number): StatItem[] => [ { badge: "live", description: `On air for ${clock(seconds)}`, details: [{ label: "Started", value: "8:12 PM" }], id: "session", label: "Session", tone: "neutral", value: clock(seconds), }, { description: "1.2k viewers, peak 1.4k", details: [ { label: "YouTube", platform: "youtube", value: "812" }, { label: "Twitch", platform: "twitch", value: "356" }, { label: "X", platform: "x", value: "64" }, { label: "Peak", value: "1.4k" }, ], id: "viewers", label: "Viewers", spark: [880, 940, 1010, 990, 1080, 1150, 1120, 1232], tone: "neutral", value: "1.2k", }, { delta: "+12", description: "61,942 followers, +12 this stream", details: [ { label: "YouTube subscribers", note: "+9 this stream", platform: "youtube", value: "48,310", }, { label: "Twitch followers", note: "+3 this stream", platform: "twitch", value: "13,632", }, ], id: "followers", label: "Followers", tone: "neutral", unit: "followers", value: "61,942", }, { description: "8 new supporters this stream", details: [{ label: "New this stream", value: "8" }], id: "supporters", label: "Supporters this stream", tone: "neutral", unit: "supporters", value: "8", }, { description: "Tips this stream: $20, 300 bits", details: [ { label: "Super Chats (USD)", value: "$20" }, { label: "Bits", value: "300" }, ], id: "tips", label: "Tips this stream", tone: "neutral", value: "$20 · 300 bits", }, { description: `${chatPace} messages a minute`, details: [ { label: "Messages in the last minute", value: String(chatPace) }, ], id: "chat", label: "Chat", tone: "neutral", unit: "msg/min", value: String(chatPace), }, ]; const DESTINATIONS: ChatDestination[] = [ { accountLabel: "Night Owl Studio", id: "yt", platform: "youtube", state: "connected", write: "ready", }, { accountLabel: "nightowlstudio", id: "tw", platform: "twitch", state: "connected", write: "ready", }, { accountLabel: "@nightowlstudio", id: "x", platform: "x", state: "connected", write: "read-only", }, ]; const MESSAGES: ChatMessageData[] = [ { authorName: "Maya Chen", id: "m1", platform: "youtube", text: "Which microphone are you using on stream today?", time: "8:39 PM", }, { authorName: "nightowl_jonas", authorRoles: ["member"], id: "m2", platform: "twitch", text: "The new two-camera scene looks so clean.", time: "8:40 PM", }, { authorName: "Priya Raman", id: "m3", platform: "x", text: "Watching from Lisbon. Audio is perfectly in sync!", time: "8:40 PM", }, { amountText: "$20.00", authorName: "Leo Park", id: "m4", kind: "paid", platform: "youtube", text: "Bought my first capture card because of this channel!", time: "8:41 PM", }, { authorName: "kai.streams", firstMessage: true, id: "m5", platform: "twitch", text: "@nightowlstudio first time here, is this the Studio scene you built last week?", time: "8:41 PM", }, { authorName: "Ana Silva", authorRoles: ["moderator"], id: "m6", platform: "youtube", reply: { authorName: "Maya Chen", text: "Which microphone are you using?" }, text: "It's the Shure MV7, he said it last week.", time: "8:42 PM", }, { authorName: "Sam Okafor", id: "m7", platform: "twitch", text: "What mic is that? Sounds great", time: "8:42 PM", }, { authorName: "Hana Ito", id: "m8", platform: "x", text: "Can you show the scene setup again?", time: "8:43 PM", }, ]; /** New chat while the window is open, one every few seconds. */ const ARRIVALS: Omit[] = [ { authorName: "pixelpilot", platform: "twitch", text: "Raid squad says hi!" }, { authorName: "Maya Chen", platform: "youtube", text: "The captions are so fast now.", }, { authorName: "Priya Raman", platform: "x", text: "Clip that!" }, { authorName: "nightowl_jonas", authorRoles: ["member"], platform: "twitch", text: "Fourteen months and the stream keeps getting better.", }, { authorName: "Leo Park", platform: "youtube", text: "Will you put the overlay pack in the description?", }, ]; /** Messages behind Buddy's open question: two people asking about the mic. */ const QUESTION_IDS = new Set(["m1", "m7"]); const MENTION_NAMES = ["@nightowlstudio"]; const ACTIVITY: ActivityItem[] = [ { id: "a1", kind: "super-chat", line: "Sent a $20.00 Super Chat", message: "Bought my first capture card because of this channel!", messageId: "m4", name: "Leo Park", platform: "youtube", short: "Super Chat · $20.00", time: "2m", }, { id: "a2", kind: "subscription", line: "Resubscribed for 14 months at Tier 1", message: "Fourteen months of two-camera streams.", name: "nightowl_jonas", platform: "twitch", short: "Resub · 14 months", time: "4m", }, { gift: true, id: "a3", kind: "subscription", line: "Gifted 5 Tier 1 subs to the community", name: "kai.streams", platform: "twitch", short: "Gifted 5 subs", time: "6m", }, { id: "a4", kind: "raid", line: "Raided with 42 viewers", name: "pixelpilot", platform: "twitch", short: "Raid · 42 viewers", time: "9m", }, { id: "a5", kind: "follow", line: "3 new followers on X", name: "3 new followers", platform: "x", short: "", time: "12m", unnamed: true, }, ]; const COMMANDS: ChatComposerCommand[] = [ { argument: true, hint: " [title] · Save a timeline point", value: "/marker", }, { hint: " · Save the last 30 seconds as a clip", value: "/clip" }, { hint: " · Save the last minute as a clip", value: "/clip 60" }, { hint: " · Hide screen", value: "/hide" }, { hint: " · Show screen", value: "/show" }, { hint: " · Command help", value: "/help" }, ]; // Stand-ins for the macOS traffic lights, which the OS draws in a real window. const TrafficLights = () => (
      ); /** The Buddy pane, as this demo draws it: its header and one question card. */ const BuddyPane = ({ onShowQuestion }: { onShowQuestion: () => void }) => { const [answered, setAnswered] = useState(false); return (
      Orcle Alpha
      {answered ? (

      Orcle reads chat during a live stream: questions, flags and the room's mood.

      ) : ( 2 people asking Which microphone are you using on stream today?
      )}
      ); }; const nowTime = (): string => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); const StreamManagerDemo = () => { const [seconds, setSeconds] = useState(STARTED_SECONDS); const [messages, setMessages] = useState(MESSAGES); const [activity, setActivity] = useState(ACTIVITY); const [arrived, setArrived] = useState(0); const [highlight, setHighlight] = useState({ messageId: "m3", phase: "live", }); const [layout, setLayout] = useState(DEFAULT_STATS_LAYOUT); const [pinned, setPinned] = useState(true); const [position, setPosition] = useState("bottom-left"); const [screenHidden, setScreenHidden] = useState(false); const [autoShow, setAutoShow] = useState(true); const [pending, setPending] = useState(false); const [notice, setNotice] = useState(null); useEffect(() => { const timer = setInterval(() => setSeconds((value) => value + 1), TICK_MS); return () => clearInterval(timer); }, []); useEffect(() => { let count = 0; const timer = setInterval(() => { count += 1; const next = ARRIVALS[(count - 1) % ARRIVALS.length]; setArrived(count); if (next) { setMessages((current) => [...current, { ...next, id: `new-${count}`, time: nowTime() }].slice( -MOST_MESSAGES ) ); } }, ARRIVAL_MS); return () => clearInterval(timer); }, []); // A show takes a moment while Studio puts it on stream. const toggleOnStream = (messageId: string): void => { if (highlight?.messageId === messageId && highlight.phase === "live") { setHighlight(null); return; } setHighlight({ messageId, phase: "applying" }); setTimeout(() => setHighlight({ messageId, phase: "live" }), APPLY_MS); }; return ( // The window and the stand-in traffic lights on its title row.
      toggleOnStream("m1")} />, tone: "good", toneLabel: "Buddy is listening", unseen: 1, }} className="rounded-panel h-full border" composerCommands={COMMANDS} composerNotice={notice} composerPlaceholder="Message, /marker [title] or /clip…" destinationNotes={{ x: ["X live chat reads only on this stream."] }} destinations={DESTINATIONS} highlight={highlight} highlightPosition={position} live mentionNames={MENTION_NAMES} messages={messages} onAutoShowChange={setAutoShow} onClear={() => { setMessages([]); setActivity([]); setHighlight(null); }} onComposerCommand={(command) => setNotice( command.startsWith("/marker") ? `Marker saved at ${clock(seconds)} · ${command.slice(8).trim() || "Untitled marker"}` : "Saving the last 30 seconds as a clip…" ) } onHighlight={(message) => toggleOnStream(message.id)} onHighlightPositionChange={setPosition} onMarkClip={(length) => setNotice( length === 30 ? "Saving the last 30 seconds as a clip…" : "Saving the last minute as a clip…" ) } onOpenPreview={() => setNotice("The Preview window opens.")} onPinnedChange={setPinned} onScreenPrivacy={setScreenHidden} onSend={(text) => { setNotice(null); setPending(true); setTimeout(() => { setPending(false); setMessages((current) => [ ...current, { authorName: "Night Owl Studio", authorRoles: ["owner"], id: `sent-${current.length}`, platform: "youtube", text, time: nowTime(), }, ]); }, SEND_MS); }} onShowActivityOnStream={(item) => item.messageId ? toggleOnStream(item.messageId) : undefined } onStatsLayoutChange={setLayout} pinned={pinned} platform="darwin" questionIds={QUESTION_IDS} screenHidden={screenHidden} sendPending={pending} stats={statItems(seconds, 18 + (arrived % 7))} statsLayout={layout} thankYouDraft={(item) => `Thank you, ${item.name}!`} />
      ); }; export default StreamManagerDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/stream-manager ``` This installs every part with it: [Chat Message](/docs/blocks/chat-message), [Chat Toolbar](/docs/blocks/chat-toolbar), [Chat Composer](/docs/blocks/chat-composer), [Stream Stats Bar](/docs/blocks/stream-stats-bar), [Activity Feed](/docs/blocks/activity-feed) and [Stream Manager Status Bar](/docs/blocks/stream-manager-status-bar). ## Usage ```tsx import { StreamManager } from "@/components/blocks/stream-manager/stream-manager"; ``` ```tsx ``` The window is presentational: your app owns the session and passes it in as plain data, and every action comes back as a callback. Nothing reaches for Electron, IPC or `window.videorc`. | Prop | The data | | --- | --- | | `stats` | `StatItem[]`: the clock, viewers, followers and the rest ([Stream Stats Bar](/docs/blocks/stream-stats-bar)) | | `messages` | `ChatMessageData[]`, oldest first ([Chat Message](/docs/blocks/chat-message)) | | `destinations` | `ChatDestination[]`: each platform's chat state and whether it sends ([Chat Platform](/docs/blocks/chat-platform)) | | `activity` | `ActivityItem[]`: follows, subs, gifts, tips and raids ([Activity Feed](/docs/blocks/activity-feed)) | | `highlight` | `{ messageId, phase }`: the message on stream, the one slot Chat and Activity share | | `buddy` | `{ name, tone, unseen, pane }`: Buddy's tab and its pane, when there is one | A row's click calls `onHighlight`, and the window shows whatever `highlight` you pass back: `applying` while Studio puts it on stream, then `live`, or `failed` with its reason. ## The window - **Three tiers by its own width.** The body is `@container/stream-manager`, never the viewport. From 1,040 px Chat sits beside a right pane with Activity and Buddy tabs, and the stats bar draws its sparklines. From 640 px one pane sits behind Chat, Activity and Buddy tabs. Below 640 px (320 minimum) the filters and the status bar's controls fold into menus. Every pane renders once; only its placement changes. Give it any size: a window, a split pane or a docs preview. - **The title row carries the title only.** The stats bar says ON AIR, and a finished session (`history`) gets its own bar with Back to live. Pass the `platform` you know so the row leaves room for the macOS traffic lights. - **Live and not.** While `live`, a click puts a message on stream, the composer shows, and a row's time and ⋯ wait for the pointer. Off air, chat says how it starts and the rows are plain text. - **Keys.** While focus is in the window, ⌘F searches chat, ⌘L writes in it and ⌘J brings Buddy forward. The status bar prints the same keys as clickable hints. - **Replies and thanks fill the composer, never send.** A row's ⋯ Reply writes "@name ", and Activity's Thank in chat writes what `thankYouDraft` returns: the streamer sends. ## Examples ### History Without `live` the window reads a session back: `history` adds its bar with Back to live, the stats are the session's totals, and the rows are plain text with their times. There's no composer, and an empty chat says how it starts. ```tsx title="components/examples/stream-manager-off-air.tsx" "use client"; import type { ChatMessageData } from "@/components/blocks/stream-manager/chat-message"; import type { ChatDestination } from "@/components/blocks/stream-manager/chat-platform"; import { StreamManager } from "@/components/blocks/stream-manager/stream-manager"; import type { StatItem } from "@/components/blocks/stream-manager/stream-stats-bar"; const STATS: StatItem[] = [ { badge: "history", description: "History: Building a two-camera podcast scene, Oct 29", details: [{ label: "Title", value: "Building a two-camera podcast scene" }], id: "session", label: "Session", tone: "neutral", value: "Oct 29", }, { description: "Peak 1.4k viewers, average 1.1k", details: [ { label: "Peak", value: "1.4k" }, { label: "Average", value: "1.1k" }, ], id: "viewers", label: "Viewers", tone: "neutral", unit: "peak", value: "1.4k", }, { description: "2,318 messages, 412 chatters", details: [{ label: "Chatters", value: "412" }], id: "chat", label: "Chat", tone: "neutral", unit: "messages", value: "2,318", }, ]; const DESTINATIONS: ChatDestination[] = [ { id: "yt", platform: "youtube", state: "ended", write: "ready" }, { id: "tw", platform: "twitch", state: "ended", write: "ready" }, ]; const MESSAGES: ChatMessageData[] = [ { authorName: "Maya Chen", id: "h1", platform: "youtube", text: "Which microphone are you using on stream today?", time: "8:39 PM", }, { authorName: "nightowl_jonas", authorRoles: ["member"], id: "h2", platform: "twitch", text: "Great stream, see you Thursday!", time: "9:58 PM", }, ]; const StreamManagerOffAir = () => ( null, title: "Building a two-camera podcast scene", }} messages={MESSAGES} platform="win32" stats={STATS} /> ); export default StreamManagerOffAir; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `@container/stream-manager` | The window body; every part's tiers read it | | `@min-[1040px]/stream-manager:` | Wide: Chat beside the right pane | | `bg-glass-content` | The window's content coat (`WindowFrame`) | | `data-pane` | `chat`, `activity`, `buddy` | | `data-slot` | `stream-manager`, `stream-manager-panes`, `pane-tabs`, `pane-tabs-wide`, `chat-pane`, `history-bar` | ## Keyboard | Key | Action | | --- | --- | | ⌘ F | Searches chat | | ⌘ L | Writes in chat, while live | | ⌘ J | Brings Buddy's pane forward | | Enter Space | On a message, puts it on stream or takes it off | On Windows and Linux the keys are Ctrl. They answer while focus is inside the window, so a page around it keeps its own. ## Accessibility The title is the window's `h1`. The stats bar, Chat and Activity are named regions, and the pane tabs are a tab list; Buddy's dot says what it means through `buddy.toneLabel`, never colour alone. Each part brings its own: see their pages. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | `"Stream Manager"` | — | | `platform` | `string` | — | darwin, win32, linux… | | `live` | `boolean` | `false` | A live session. | | `history` | `{ title: string; date: string; onBackToLive?: () => void }` | — | A finished session, read back. | | `stats` | `readonly StatItem[]` | — | — | | `statsLayout` | `StatsLayout` | — | — | | `onStatsLayoutChange` | `(layout: StatsLayout) => void` | — | — | | `screenHidden` | `boolean` | `false` | — | | `messages` | `readonly ChatMessageData[]` | — | — | | `destinations` | `readonly ChatDestination[]` | — | — | | `mentionNames` | `readonly string[]` | — | Your accounts: @you and Mentions. | | `questionIds` | `ReadonlySet` | — | Messages behind Buddy's open questions. | | `highlight` | `{ messageId: string; phase: "applying" \| "live" \| "failed"; reason?: string } \| null` | — | The message on stream. | | `onHighlight` | `(message) => void` | — | — | | `removals` | `Record` | — | By message id. | | `flags` | `Record` | — | Buddy's flags, by message id. | | `suggestedIds` | `ReadonlySet` | — | — | | `spotlightId` | `string \| null` | — | — | | `onRemoveFromChat` | `(message) => void` | — | — | | `onSend` | `(text, options) => void` | — | The composer shows while live. | | `sendPending` | `boolean` | `false` | — | | `sendFailures` | `readonly ChatSendFailure[]` | — | — | | `onRetrySend` | `() => void` | — | — | | `composerCommands` | `readonly ChatComposerCommand[]` | — | — | | `onComposerCommand` | `(text: string) => void` | — | — | | `composerNotice` | `ReactNode` | — | — | | `composerPlaceholder` | `string` | — | — | | `activity` | `readonly ActivityItem[]` | `[]` | — | | `activityNote` | `string \| null` | — | — | | `onShowActivityOnStream` | `(item) => void` | — | — | | `thankYouDraft` | `(item) => string` | — | Adds Thank in chat. | | `twitchNeeds` | `{ followNames?: boolean; bitsAndPoints?: boolean }` | — | — | | `onReconnectTwitch` | `() => void` | — | — | | `autoShow` | `boolean` | — | — | | `onAutoShowChange` | `(on: boolean) => void` | — | — | | `buddy` | `StreamManagerBuddy` | — | Buddy's tab and pane. | | `unseen` | `{ chat?: number; activity?: number }` | — | Counts on the tabs. | | `pinned` | `boolean` | — | — | | `onPinnedChange` | `(pinned: boolean) => void` | — | — | | `destinationNotes` | `Partial>` | — | — | | `highlightPosition` | `HighlightPosition` | — | — | | `onHighlightPositionChange` | `(position) => void` | — | — | | `onMarkClip` | `(seconds: 30 \| 60) => void` | — | While live. | | `onScreenPrivacy` | `(hidden: boolean) => void` | — | — | | `banners` | `readonly StreamBanner[] \| null` | — | — | | `onBanner` | `(action: BannerAction) => void` | — | — | | `onClear` | `() => void` | — | — | | `onOpenPreview` | `() => void` | — | — | ### StreamManagerBuddy | Prop | Type | Default | Description | | --- | --- | --- | --- | | `name` | `string` | — | The Buddy's name, on its tab: "Orcle". | | `tone` | `"good" \| "warn" \| "error" \| "neutral"` | `"neutral"` | Its presence: good while listening. | | `toneLabel` | `string` | — | "Buddy is listening", read aloud. | | `unseen` | `number` | — | New questions and cards behind the tab. | | `pane` | `ReactNode` | — | The Buddy pane. | # Stream Stats Bar (/docs/blocks/stream-stats-bar) ```tsx title="components/examples/stream-stats-bar-demo.tsx" "use client"; import { useEffect, useState } from "react"; import { DEFAULT_STATS_LAYOUT, StreamStatsBar, } from "@/components/blocks/stream-manager/stream-stats-bar"; import type { StatItem, StatsLayout, } from "@/components/blocks/stream-manager/stream-stats-bar"; const STARTED_SECONDS = 30 * 60 + 44; const SECONDS_PER_MINUTE = 60; const SECONDS_PER_HOUR = 3600; const TICK_MS = 1000; /** "30:44", or "1:02:03" past the hour. */ const formatClock = (seconds: number): string => { const hours = Math.floor(seconds / SECONDS_PER_HOUR); const minutes = Math.floor((seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); const rest = String(seconds % SECONDS_PER_MINUTE).padStart(2, "0"); return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` : `${minutes}:${rest}`; }; const statItems = (seconds: number): StatItem[] => [ { badge: "live", description: `On air for ${formatClock(seconds)}`, details: [{ label: "Started", value: "8:12 PM" }], id: "session", label: "Session", tone: "neutral", value: formatClock(seconds), }, { description: "1.2k viewers, peak 1.4k", details: [ { label: "YouTube", platform: "youtube", value: "812" }, { label: "Twitch", platform: "twitch", value: "356" }, { label: "X", platform: "x", value: "64" }, { label: "Peak", value: "1.4k" }, ], id: "viewers", label: "Viewers", spark: [880, 940, 1010, 990, 1080, 1150, 1120, 1232], tone: "neutral", value: "1.2k", }, { delta: "+12", description: "61,942 followers, +12 this stream", details: [ { label: "YouTube subscribers", note: "+9 this stream", platform: "youtube", value: "48,310", }, { label: "Twitch followers", note: "+3 this stream", platform: "twitch", value: "13,632", }, ], id: "followers", label: "Followers", tone: "neutral", unit: "followers", value: "61,942", }, { description: "8 new supporters this stream", details: [{ label: "New this stream", value: "8" }], id: "supporters", label: "Supporters this stream", tone: "neutral", unit: "supporters", value: "8", }, { description: "Tips this stream: $20, 300 bits", details: [ { label: "Super Chats (USD)", value: "$20" }, { label: "Bits", value: "300" }, ], id: "tips", label: "Tips this stream", tone: "neutral", value: "$20 · 300 bits", }, { description: "22 messages a minute, 318 chatters", details: [ { label: "Messages in the last minute", value: "22" }, { label: "Chatters", value: "318" }, ], id: "chat", label: "Chat", tone: "neutral", unit: "msg/min", value: "22", }, ]; const StreamStatsBarDemo = () => { const [seconds, setSeconds] = useState(STARTED_SECONDS); const [layout, setLayout] = useState(DEFAULT_STATS_LAYOUT); useEffect(() => { const timer = setInterval(() => setSeconds((value) => value + 1), TICK_MS); return () => clearInterval(timer); }, []); return ( // The Stream Manager's container: the bar's tiers follow its width.
      ); }; export default StreamStatsBarDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/stream-stats-bar ``` This adds `stream-stats-bar.tsx` and its sparkline, `stats-sparkline.tsx`, and installs `recharts`. ## Usage ```tsx import { DEFAULT_STATS_LAYOUT, StreamStatsBar, } from "@/components/blocks/stream-manager/stream-stats-bar"; import type { StatItem } from "@/components/blocks/stream-manager/stream-stats-bar"; ``` ```tsx const items: StatItem[] = [ { id: "session", label: "Session", value: "30:44", badge: "live", tone: "neutral", details: [{ label: "Started", value: "8:12 PM" }], description: "On air for 30:44", }, { id: "viewers", label: "Viewers", value: "1.2k", tone: "neutral", spark: [880, 940, 1010, 1232], details: [{ label: "YouTube", value: "812", platform: "youtube" }], description: "1.2k viewers, peak 1.4k", }, ]; ; ``` Each stat is plain data you compute: its value as shown, a unit, a change, a tone, its sparkline points and its hover card's rows. The bar never measures anything itself. - **Never an unmeasured zero.** Give a stat only when its source exists for this session, and show "–" with the reason in its details when a number can't be read. - **Quiet when fine.** Numbers stay monochrome and tabular. Tone lives in the session chip (ON AIR is the only emphasis) and in a stat's sparkline when it's in trouble. - **Main slots first.** The first three stats in the layout are drawn larger and never clipped: by default the clock, viewers and followers. The rest follow after a hairline in a clipped, wrapping row, so a stat that doesn't fit drops off the end whole. ## Examples ### Off air, with the screen hidden Off air the clock says so in the subtle tier. `screenHidden` closes the bar with the "Screen hidden" chip, which never moves a stat. ```tsx title="components/examples/stream-stats-bar-off-air.tsx" "use client"; import { StreamStatsBar } from "@/components/blocks/stream-manager/stream-stats-bar"; import type { StatItem } from "@/components/blocks/stream-manager/stream-stats-bar"; // Off air, nothing is measured: the clock says so, and the chat pace shows // "–" with the reason on hover, never a zero nobody counted. const ITEMS: StatItem[] = [ { badge: "off-air", description: "Off air", details: [], id: "session", label: "Session", tone: "subtle", value: "Off air", }, { description: "Chat isn't connected", details: [ { label: "Messages in the last minute", note: "Chat isn't connected", value: "–", }, ], id: "chat", label: "Chat", tone: "subtle", value: "–", }, ]; const StreamStatsBarOffAir = () => (
      ); export default StreamStatsBarOffAir; ``` ## Arranging stats With `onLayoutChange`, the streamer drags a stat onto another to move it, and right-clicks the bar to show or hide stats, move the one under the pointer, or reset. The clock and the viewer count can't be hidden or leave the main slots: the viewer count is never hidden while live. Keep the layout per machine, as Videorc does in `localStorage`. Without `onLayoutChange` the order is fixed. `arrangeStats`, `moveStat`, `setStatHidden` and `statsLayoutIsDefault` are the rules, and `STAT_NAMES` the stats' names, for a menu of your own (the [Stream Manager Status Bar](/docs/blocks/stream-manager-status-bar) folds the same choices into its ⋯ menu). ## Responsive The sparklines are a Wide luxury: they appear when the `@container/stream-manager` around the bar is 1,040 px or wider. Below it the number is the reading. The examples here wrap the bar in that container; the [Stream Manager](/docs/blocks/stream-manager) is one. ## Theming | Token or attribute | Meaning | | --- | --- | | `Badge` `live` | ON AIR, the only emphasis | | `text-subtle` | A reading that isn't live (`tone: "subtle"`) | | `tabular-nums` | Every number | | `@min-[1040px]/stream-manager:` | Where the sparklines appear | | `StatusBadge` `warn` | Screen hidden | | `data-stat`, `data-group`, `data-tone` | On each stat: its id, `main` or `more`, its tone | | `data-slot` | `stream-stats-bar`, `stats-main`, `stats-more`, `stat-item`, `stat-value`, `stats-sparkline` | ## Accessibility The bar is a region named "Stream stats". Each stat is one image named by its `description`, the whole reading in words, and a stat with details is a tab stop whose hover card also opens on focus. An arrangeable bar is a tab stop too, so its menu opens from the keyboard. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `items` | `readonly StatItem[]` | — | — | | `layout` | `StatsLayout` | `DEFAULT_STATS_LAYOUT` | — | | `onLayoutChange` | `(layout: StatsLayout) => void` | — | Lets the streamer drag and pick stats. Absent: a fixed order. | | `screenHidden` | `boolean` | `false` | The screen is hidden from viewers: the chip closes the bar. | ### StatItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | `id` | `"session" \| "viewers" \| "followers" \| "supporters" \| "tips" \| "chat"` | — | — | | `label` | `string` | — | The hover card's heading. | | `value` | `string` | — | The number as shown, or "–". | | `unit` | `string` | — | "followers", "msg/min". | | `delta` | `string` | — | "+12". | | `tone` | `"neutral" \| "subtle" \| "good" \| "warning" \| "error"` | — | subtle dims it; warning and error tint its sparkline. | | `badge` | `"live" \| "recording" \| "off-air" \| "history"` | — | The session's chip: ON AIR, Recording, History. | | `spark` | `readonly number[]` | — | Sparkline points, oldest first. | | `details` | `readonly StatDetailRow[]` | — | The hover card's rows: label, value, platform, note. | | `description` | `string` | — | The whole reading in words. | ### StatsLayout | Prop | Type | Default | Description | | --- | --- | --- | --- | | `order` | `StatId[]` | — | Every stat in order; the first three are the main slots. | | `hidden` | `StatId[]` | — | Stats the streamer hid. | # Accordion (/docs/components/accordion) ```tsx title="components/examples/accordion-demo.tsx" "use client"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; const AccordionDemo = () => ( Video Record at 1080p and 60 fps. Livestreams use the quality each destination accepts. Audio Your microphone and system audio are mixed into one track, with noise cleanup on. Output Recordings save as MP4 to your Movies folder. ); export default AccordionDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/accordion ``` ## Usage ```tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; ``` ```tsx Video Record at 1080p and 60 fps. ``` ## Anatomy ```tsx ``` ## Examples ### Multiple Set `multiple` to let several items stay open, as the Go Live details for each destination do. The value is always an array, open or closed, single or multiple. ```tsx title="components/examples/accordion-multiple.tsx" "use client"; import { useState } from "react"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; const DESTINATIONS = [ { details: "Public, not made for kids. Title: Launch day.", label: "YouTube", value: "youtube", }, { details: "Category: Science & Technology. Title: Launch day.", label: "Twitch", value: "twitch", }, { details: "Posts the stream link when you go live.", label: "X", value: "x", }, ]; const AccordionMultiple = () => { const [open, setOpen] = useState(["youtube", "twitch"]); return ( {DESTINATIONS.map((destination) => ( {destination.label} {destination.details} ))} ); }; export default AccordionMultiple; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `rounded-row`, `border` | The group: the 8 px row radius and one hairline | | `data-open` | On an open item, which takes a `bg-muted/50` wash | | `data-panel-open` | On the trigger of an open item; swaps the chevron | | `--accordion-panel-height` | The panel's measured height, eased in 150 ms with `data-starting-style` and `data-ending-style` | | `data-disabled` | On a disabled item and its trigger | ## Keyboard | Key | Action | | --------------------------------- | -------------------------------- | | Tab | Moves to the next trigger | | Enter Space | Opens or closes the focused item | ## Accessibility Each trigger is a button inside a heading, with `aria-expanded` and `aria-controls` pointing at its panel. Name the group when it has a job of its own (`aria-label="Details for each destination"`). ## API reference ### Accordion Every [Base UI Accordion](https://base-ui.com/react/components/accordion) Root prop. The ones that differ from Radix: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `multiple` | `boolean` | `false` | Lets several items stay open; replaces type="multiple". | | `value` | `Value[]` | — | The open items, always an array. | | `defaultValue` | `Value[]` | — | The items open at first. | | `onValueChange` | `(value: Value[], eventDetails) => void` | — | — | `AccordionItem` takes a `value`; `AccordionTrigger` and `AccordionContent` take every Base UI Trigger and Panel prop, and `AccordionContent`'s `className` styles its inner padding box. # Alert Dialog (/docs/components/alert-dialog) ```tsx title="components/examples/alert-dialog-demo.tsx" "use client"; import { useState } from "react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; const AlertDialogDemo = () => { const [open, setOpen] = useState(false); return ( }> Delete scene… Delete saved scene? Only the saved snapshot is deleted. Your current scene and recording continue. Cancel setOpen(false)} variant="destructive" > Delete scene ); }; export default AlertDialogDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/alert-dialog ``` ## Usage ```tsx import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; ``` ```tsx Delete saved scene? Only the saved snapshot is deleted. Your current scene and recording continue. Cancel Delete scene ``` The title is "Verb object?", and one sentence names exactly what is lost. The safe answer ("Cancel", or "Keep …") is a ghost button; the destructive one reads verb and object. A consequential step that destroys nothing, such as a replace, keeps `variant="default"`. ## Anatomy ```tsx ``` ## Examples ### Pending action `AlertDialogAction` doesn't close the dialog: the owner drops `open` once the action is done. While async work runs, the dialog stays up and says so ("Deleting…"), and the answers are disabled. ```tsx title="components/examples/alert-dialog-pending.tsx" "use client"; import { useState } from "react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; const DELETE_TAKES_MS = 1500; const AlertDialogPending = () => { const [open, setOpen] = useState(false); const [deleting, setDeleting] = useState(false); // The dialog stays up, saying "Deleting…", until the work is done: then // the owner closes it. const confirm = () => { setDeleting(true); setTimeout(() => { setDeleting(false); setOpen(false); }, DELETE_TAKES_MS); }; return ( { if (!deleting) { setOpen(next); } }} open={open} > }> Delete recording… Delete recording? The recording and its markers move to the Trash. Cancel {deleting ? "Deleting…" : "Delete recording"} ); }; export default AlertDialogPending; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `dialogContentVariants({ size })` | The same surface, widths and height rule as [Dialog](/docs/components/dialog) | | `data-size` | `sm`, `md` (the default), `lg` or `xl` | | `bg-black/30` | The overlay, with no backdrop blur | ## Keyboard | Key | Action | | ------------------ | --------------------------------------------- | | `Escape` | Closes the dialog: the same answer as Cancel | | `Tab`, `Shift+Tab` | Moves between the answers; focus stays inside | | `Enter`, `Space` | Presses the focused answer | ## Accessibility The dialog is an `alertdialog`, named by its title and described by its description. Cancel has focus on open, even when the body holds a control before it, so a stray `Enter` or `Space` never destroys anything. A click outside doesn't close it. ## API reference Every [Base UI Alert Dialog](https://base-ui.com/react/components/alert-dialog) prop, plus: ### AlertDialogContent | Prop | Type | Default | Description | | --- | --- | --- | --- | | `size` | `"sm" \| "md" \| "lg" \| "xl"` | `"md"` | — | | `initialFocus` | `boolean \| RefObject \| (openType) => HTMLElement \| boolean \| void` | — | Defaults to the dialog's Cancel. | ### AlertDialogAction A [Button](/docs/components/button) that doesn't close the dialog. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `Button variant` | `"default"` | — | | `size` | `Button size` | `"default"` | — | ### AlertDialogCancel | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `Button variant` | `"ghost"` | — | | `size` | `Button size` | `"default"` | — | # Alert (/docs/components/alert) ```tsx title="components/examples/alert-demo.tsx" "use client"; import { Alert, AlertAction, AlertDescription, AlertTitle, } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; const AlertDemo = () => ( Stream health is poor Viewers may see buffering. Lower the bitrate or check your upload speed. ); export default AlertDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/alert ``` ## Usage ```tsx import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; ``` ```tsx Your microphone is muted. ``` ## Anatomy ```tsx ``` ## Examples ### Variants The variant is the tone. `default` and `quiet` are notes (`quiet` in secondary text), `locked` says why the controls under it are off, and `success`, `warning` and `destructive` report an outcome. A title alone reads as a message in regular weight; over a description it becomes a heading in medium. ```tsx title="components/examples/alert-variants.tsx" "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; const AlertVariants = () => (
      Recordings save to your Movies folder. Captions appear after the recording stops. Locked while you’re recording or live. Changes apply the next time you record or go live. Recording saved Launch demo is in your library. Your microphone is muted. Couldn’t save the recording The disk is full. Free up space, then record again.
      ); export default AlertVariants; ``` ### Icon Each tone brings one glyph in one weight. Pass `icon` to show the subject's own mark instead (the microphone, a platform), or `icon={null}` for none. Never put an icon in the children. ```tsx title="components/examples/alert-icon.tsx" "use client"; import { MicrophoneIcon } from "@/components/icons"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; const AlertIcon = () => (
      No microphone found Connect a microphone, or choose one in Sources. Twitch shows your stream title above the chat.
      ); export default AlertIcon; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `rounded-row` | The 8 px row radius; no border and no shadow | | `bg-foreground/4` | The tint of `default`, `quiet` and `locked` | | `bg-success/10`, `bg-warning/10`, `bg-destructive/10` | The tone tints; the icon takes the tone colour, the text stays monochrome | | `data-variant` | The tone, for styling a part from the alert with `group-data-[variant=…]/alert` | | `data-slot="alert-icon"` | The leading icon | ## Accessibility A `destructive` alert is `role="alert"` and is announced at once. Every other tone is `role="status"`, a polite live region. Pass `role` to override it, for a card that is a group or a note that should stay silent. The icon is `aria-hidden`: the title carries the meaning. ## API reference ### Alert Every `
      ` prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `"default" \| "quiet" \| "locked" \| "success" \| "warning" \| "destructive"` | `"default"` | The tone: the tint, the icon and the role. | | `icon` | `AppIcon \| null` | — | Replaces the tone's icon; null drops it. | | `role` | `AriaRole` | `"status"` | "alert" when the variant is destructive. | `AlertTitle`, `AlertDescription` and `AlertAction` take every `
      ` prop. `AlertAction` sits in the top-right corner, and the alert pads its text to make room. # Avatar (/docs/components/avatar) ```tsx title="components/examples/avatar-demo.tsx" "use client"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; const AvatarDemo = () => (
      VR MC JD
      ); export default AvatarDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/avatar ``` ## Usage ```tsx import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; ``` ```tsx MC ``` ## Anatomy ```tsx ``` ## Examples ### Group Overlap a few avatars and end with a count for the rest, such as a channel's moderators. ```tsx title="components/examples/avatar-group.tsx" "use client"; import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, } from "@/components/ui/avatar"; const MODERATORS = [ { id: "maya", initials: "MC" }, { id: "jonas", initials: "JD" }, { id: "priya", initials: "PS" }, ]; const AvatarGroupExample = () => ( {MODERATORS.map((moderator) => ( {moderator.initials} ))} +12 ); export default AvatarGroupExample; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `data-size` | `sm` (24 px), `default` (32 px) or `lg` (40 px) | | `after:border-border` | The hairline over the picture, blended so it reads on any photo | | `bg-muted`, `text-muted-foreground` | The fallback's fill and initials | ## Accessibility Give `AvatarImage` an `alt` that names the person or channel. When the name is printed beside the avatar, as it is in a chat row, use `alt=""` and `aria-hidden` on the avatar so the name isn't read twice. ## API reference ### Avatar Every [Base UI Avatar](https://base-ui.com/react/components/avatar) Root prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `size` | `"default" \| "sm" \| "lg"` | `"default"` | — | ### AvatarFallback Every Base UI Avatar Fallback prop. `delay` waits that many milliseconds before showing the initials, so a fast image never flashes them. `AvatarBadge`, `AvatarGroup` and `AvatarGroupCount` take `` and `
      ` props. # Badge (/docs/components/badge) ```tsx title="components/examples/badge-demo.tsx" "use client"; import { Badge } from "@/components/ui/badge"; const BadgeDemo = () => (
      On air Connected 9:16 1080p60
      ); export default BadgeDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/badge ``` ## Usage ```tsx import { Badge } from "@/components/ui/badge"; ``` ```tsx Connected ``` ## Examples ### Variants `default` is neutral glass and `outline` is a tag (9:16, counts, Beta) in secondary text. `success`, `warning` and `neutral` are status chips. `destructive` and `live` tint the glass, only for failed, on air and destructive. `chrome` is a highlight's On stream, `plate` a member's chip, and `ghost` a borderless word. ```tsx title="components/examples/badge-variants.tsx" "use client"; import { Badge } from "@/components/ui/badge"; const BadgeVariants = () => (
      1080p60 Beta Connected Reconnecting Idle Failed On air On stream Member Draft
      ); export default BadgeVariants; ``` ### Status A status chip carries its tone in a 6 px dot with a halo and a soft glow. A leading icon marked `data-icon="inline-start"` replaces the dot and takes the tone. The word is a human, sentence-case state ("Reconnecting"), never a raw value. ```tsx title="components/examples/badge-status.tsx" "use client"; import { CheckIcon, WarningIcon } from "@/components/icons"; import { Badge } from "@/components/ui/badge"; import { Spinner } from "@/components/ui/spinner"; const BadgeStatus = () => (
      Connected Reconnecting Idle
      Saved Dropped frames Uploading
      ); export default BadgeStatus; ``` ### As a button Render another element with `render` when the chip is the control, as the chat's jump-to-latest chip is. A badge isn't a Base UI button, so no `nativeButton` is needed. ```tsx title="components/examples/badge-render.tsx" "use client"; import { useState } from "react"; import { ArrowDownIcon } from "@/components/icons"; import { Badge } from "@/components/ui/badge"; const BadgeRender = () => { const [unread, setUnread] = useState(12); return ( setUnread(0)} type="button" />} > {unread > 0 ? `${unread} new` : "Latest"} ); }; export default BadgeRender; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `glass-chip` | The neutral glass: a gradient fill, a 1 px rim, a top highlight and a soft drop | | `glass-chip-tinted` | The emphasis glass, tinted by the tone | | `tone-success`, `tone-warning`, `tone-neutral`, `tone-destructive`, `tone-live` | Set `--chip-tone` for the dot, the leading icon or the tint | | `glass-dot` | The status dot, drawn by the chip's `::before` | | `chrome-fill`, `chrome-plate` | The `chrome` and `plate` chips | | `rounded-tag` | The 7 px tag radius, between chip and row | | `data-variant`, `data-slot="badge"` | The variant, for styling from a parent | ## Accessibility A badge isn't focusable, so a `title` on it never reaches the keyboard or a screen reader. To explain a chip, wrap it in [Info tooltip](/docs/components/info-tooltip). A chip rendered as a `
      ); export default ButtonDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/button ``` ## Usage ```tsx import { Button } from "@/components/ui/button"; ``` ```tsx ``` ## Examples ### Variants `default` is the one chrome fill on a surface: the primary action. `secondary` is the everyday button, a glass chip. `ghost` sits in toolbars and rows, `outline` beside a field, and `destructive` deletes. ```tsx title="components/examples/button-variants.tsx" "use client"; import { Button } from "@/components/ui/button"; const ButtonVariants = () => (
      ); export default ButtonVariants; ``` ### Chromatic and solid Two more fills for when the chrome default isn't the right voice. `solid` is the regular button: a flat fill in the primary ink, with no chrome. `chromatic` is a face of the theme's solid in a 2px ring of liquid chrome: bands of silver flow around the ring, glints run against them, and hover surges the flow. `chromatic` is always moving, so give it one moment on a screen, not every button. Under reduced motion, the ring's metal holds still. ```tsx title="components/examples/button-styles.tsx" import { SparkleIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; const ButtonStyles = () => (
      ); export default ButtonStyles; ``` ### Sizes Every control is 28 px tall. `xs` and `icon-xs` (24 px) are for dense rows only; `lg` (32 px) is for a dialog's or a card's single call to action. ```tsx title="components/examples/button-sizes.tsx" "use client"; import { MoreIcon, SettingsIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; const ButtonSizes = () => (
      ); export default ButtonSizes; ``` ### Record `record` is for Record and its stop button, nothing else: metallic red, polished like the default's chrome. Set `data-lit` while a recording or livestream runs, and it turns the brand red and its glow breathes. Delete stays `destructive`. For the whole control, with its glyph, its words and the starting and stopping states, use [Record Button](/docs/primitives/record-button). ```tsx title="components/examples/button-record.tsx" "use client"; import { useState } from "react"; import { RecordIcon, StopIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; const ButtonRecord = () => { const [recording, setRecording] = useState(false); return ( ); }; export default ButtonRecord; ``` ### As a link A link that looks like a button is still a link: put `buttonVariants()` on a plain ``. Rendering an `` through `render` with `nativeButton={false}` keeps Base UI's button behaviour and adds `role="button"`, which is right only for an element that acts like a button. ```tsx title="components/examples/button-link.tsx" "use client"; import { ExternalLinkIcon } from "@/components/icons"; import { buttonVariants } from "@/components/ui/button"; const ButtonLink = () => ( Open videorc.com ); export default ButtonLink; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `chrome-fill` | The default fill: polished silver in dark, graphite in light | | `chrome-led` | Record's metallic red, lit by `data-lit`, with a `--chrome-led-glow` glow | | `chrome-chromatic` | The chromatic ring: `--chrome-chromatic-bands` of metal around `--chrome-chromatic-face-stops` | | `glass-chip` | The secondary fill | | `h-control`, `size-control` | The 28 px desktop control height | | `data-variant`, `data-size` | The variant and size, for styling from a parent | ## Accessibility An icon-only button needs an `aria-label`. Icons inside a labelled button are decorative; mark their position with `data-icon="inline-start"` or `"inline-end"` so the padding adjusts. Disabled buttons take no hover, so explain why with [Disabled reason](/docs/components/disabled-reason). ## API reference Every [Base UI Button](https://base-ui.com/react/components/button) prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `"default" \| "secondary" \| "outline" \| "ghost" \| "destructive" \| "link" \| "record" \| "solid" \| "chromatic"` | `"default"` | — | | `size` | `"default" \| "xs" \| "lg" \| "icon" \| "icon-xs"` | `"default"` | — | | `render` | `ReactElement \| (props, state) => ReactElement` | — | Replaces the rendered element. | | `nativeButton` | `boolean` | `true` | Set to false when render is not a button. | `buttonVariants({ variant, size })` returns the classes, for a link or any element styled as a button. # Chart (/docs/components/chart) ```tsx title="components/examples/chart-demo.tsx" "use client"; import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis, } from "recharts"; import { ChartContainer, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; const TARGET_KBPS = 6000; // The outgoing bitrate over the first hour of a livestream: steady at the // target, with one dip when the network dropped. const BITRATE = [ { kbps: 6010, time: "0:00" }, { kbps: 5980, time: "0:05" }, { kbps: 6040, time: "0:10" }, { kbps: 5990, time: "0:15" }, { kbps: 6020, time: "0:20" }, { kbps: 5210, time: "0:25" }, { kbps: 3870, time: "0:30" }, { kbps: 4960, time: "0:35" }, { kbps: 5940, time: "0:40" }, { kbps: 6010, time: "0:45" }, { kbps: 6030, time: "0:50" }, { kbps: 6000, time: "0:55" }, ]; // Neutral: the chrome is monochrome, and a steady stream is nothing to flag. const config = { kbps: { color: "var(--foreground)", label: "Bitrate (kbps)" }, } satisfies ChartConfig; const ChartDemo = () => ( `${value / 1000}K`} tickLine={false} ticks={[0, 2000, 4000, 6000, 8000]} width={32} /> } cursor={false} /> ); export default ChartDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/chart ``` ## Usage ```tsx import { Area, AreaChart } from "recharts"; import { ChartContainer, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; ``` ```tsx const config = { kbps: { color: "var(--foreground)", label: "Bitrate (kbps)" }, } satisfies ChartConfig; } /> ; ``` Every key in the config becomes a `--color-` variable on the chart, per theme. Point a mark's `fill` and `stroke` at it. ## Anatomy ```tsx } /> } /> ``` ## Examples ### Sparklines A stats sparkline has no axes, no tooltip and no animation: the number above it is the reading. It's neutral by default and takes the warning tone only when the tile itself is a warning, with an icon beside its label so the colour is never alone. ```tsx title="components/examples/chart-sparkline.tsx" "use client"; import { Area, AreaChart, YAxis } from "recharts"; import { WarningIcon } from "@/components/icons"; import { ChartContainer } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; const neutral = { value: { color: "var(--muted-foreground)", label: "Value" }, } satisfies ChartConfig; // Colour only when the tile itself is a warning. const warning = { value: { color: "var(--warning)", label: "Value" }, } satisfies ChartConfig; const toPoints = (values: number[]) => values.map((value, index) => ({ index, value })); const Sparkline = ({ config, values, }: { config: ChartConfig; values: number[]; }) => ( // No axes, no tooltip, no animation: the number above it is the reading. ); const ChartSparkline = () => (
      Bitrate 6,012 kbps
      Viewers 214
      Dropped frames 2.4%
      ); export default ChartSparkline; ``` ### Bars One neutral series with its value at the tip of each bar. The platform names on the axis carry identity, so the bars need no colour of their own. ```tsx title="components/examples/chart-bar.tsx" "use client"; import { Bar, BarChart, LabelList, XAxis, YAxis } from "recharts"; import { ChartContainer, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; import type { ChartConfig } from "@/components/ui/chart"; const VIEWERS = [ { platform: "YouTube", viewers: 128 }, { platform: "Twitch", viewers: 86 }, { platform: "Kick", viewers: 12 }, ]; // One neutral series: the platform names on the axis carry identity. const config = { viewers: { color: "var(--muted-foreground)", label: "Viewers" }, } satisfies ChartConfig; const ChartBar = () => ( } cursor={false} /> ); export default ChartBar; ``` ## Theming The theme has no `--chart-*` palette. Videorc's chrome is monochrome and colour carries meaning, so a chart colours its series from the tokens it already has: | Colour | When | | --- | --- | | `var(--foreground)`, `var(--muted-foreground)` | Every series, by default | | `var(--warning)` | A reading that needs attention | | `var(--destructive)` | A failure | | `glass-float` | The tooltip: the popup glass with its rim and drop | | `rounded-row` | The tooltip's 8 px radius | | `stroke-border`, `fill-muted-foreground` | Grid lines, reference lines and axis text | Give a series a `theme` instead of a `color` when it needs a different value in light and dark. ## Accessibility Recharts' `accessibilityLayer` (on by default) lets the keyboard move through a chart's points and opens the tooltip on each. A chart is never the only place its numbers live: say the current reading in words beside it, and mark a decorative sparkline `aria-hidden`. ## API reference ### ChartContainer Every `div` prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `config` | `ChartConfig` | — | Each series' label, icon and color (or theme: { light, dark }). | | `initialDimension` | `{ width: number; height: number }` | `{ width: 320, height: 200 }` | The first frame's size, before the container is measured. | ### ChartTooltipContent Every Recharts `Tooltip` content prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `indicator` | `"dot" \| "line" \| "dashed"` | `"dot"` | — | | `hideLabel` | `boolean` | `false` | — | | `hideIndicator` | `boolean` | `false` | — | | `nameKey` | `string` | — | The payload key that names a series. | | `labelKey` | `string` | — | The payload key for the tooltip's label. | ### ChartLegendContent | Prop | Type | Default | Description | | --- | --- | --- | --- | | `hideIcon` | `boolean` | `false` | — | | `nameKey` | `string` | — | The payload key that names a series. | `ChartTooltip` and `ChartLegend` are Recharts' `Tooltip` and `Legend`. # Checkbox (/docs/components/checkbox) ```tsx title="components/examples/checkbox-demo.tsx" "use client"; import { Checkbox } from "@/components/ui/checkbox"; import { Field, FieldLabel } from "@/components/ui/field"; const CheckboxDemo = () => ( Include markers in the export ); export default CheckboxDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/checkbox ``` ## Usage ```tsx import { Checkbox } from "@/components/ui/checkbox"; ``` ```tsx Include markers in the export ``` ## Examples ### With a description Put the label and a `FieldDescription` in a `FieldContent` beside the checkbox. The description describes the checkbox, and a disabled one says why. ```tsx title="components/examples/checkbox-description.tsx" "use client"; import { Checkbox } from "@/components/ui/checkbox"; import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, } from "@/components/ui/field"; const CheckboxDescription = () => ( Keep original recording Clean cut saves a new file and leaves the original where it is. Upload to YouTube when it ends Connect YouTube in Livestream first. ); export default CheckboxDescription; ``` ### Indeterminate A header checkbox over some of its rows is `indeterminate`: it fills like checked and draws a dash. Clear it once every row, or none, is checked. ```tsx title="components/examples/checkbox-indeterminate.tsx" "use client"; import { useState } from "react"; import { Checkbox } from "@/components/ui/checkbox"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; const RECORDINGS = [ "Friday night speedruns", "Q&A with chat", "Studio test, take 3", ]; const CheckboxIndeterminate = () => { const [selected, setSelected] = useState(RECORDINGS.slice(0, 1)); const all = selected.length === RECORDINGS.length; const some = selected.length > 0 && !all; return (
      setSelected(all ? [] : RECORDINGS)} /> Select all recordings in view
      {RECORDINGS.map((recording) => ( setSelected((current) => checked ? [...current, recording] : current.filter((item) => item !== recording) ) } /> {recording} ))}
      ); }; export default CheckboxIndeterminate; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `chrome-fill` | The fill and ink while checked or indeterminate | | `bg-input/90` | The recessed box while unchecked | | `rounded-chip` | The 6 px control radius | | `data-checked`, `data-unchecked`, `data-indeterminate` | The state, from Base UI | | `data-disabled` | Dims to 50% | | `aria-invalid` | Draws the destructive border and ring | ## Keyboard | Key | Action | | ---------------- | ---------------------------------- | | Space | Toggles the checkbox | | Enter | Submits its form; it never toggles | | Tab | Moves focus to the next control | ## Accessibility The checkbox is a `role="checkbox"` span with `aria-checked` (`"mixed"` while indeterminate), beside a hidden native input that carries its form value. Its `id` goes on that hidden input, so a `FieldLabel` or a [Label](/docs/components/label) with `htmlFor` toggles it on click, and Base UI names the checkbox by that label. A row of checkboxes with no visible labels, such as a list's select column, names each one with `aria-label`. ## API reference Every [Base UI Checkbox](https://base-ui.com/react/components/checkbox) root prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `checked` | `boolean` | — | The controlled state. | | `defaultChecked` | `boolean` | `false` | The uncontrolled starting state. | | `indeterminate` | `boolean` | `false` | Fills like checked and draws a dash, for a header over some of its rows. | | `onCheckedChange` | `(checked: boolean, eventDetails) => void` | — | Called with the next state. | | `id` | `string` | — | Lands on the hidden input. Inside a Field, one is made when it is missing, and the FieldLabel points at it. | # Chrome Row (/docs/components/chrome-row) ```tsx title="components/examples/chrome-row-demo.tsx" "use client"; import { Badge } from "@/components/ui/badge"; import { CHROME_ROW_CLASS } from "@/components/ui/chrome-row"; import { cn } from "@/lib/utils"; const ChromeRowDemo = () => (
      1. jonas The new overlay looks great.
      2. nightowl On stream
        What camera is that?
      3. maya_streams Member
        Hi from Lisbon!
      ); export default ChromeRowDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/chrome-row ``` ## Usage ```tsx import { CHROME_ROW_CLASS } from "@/components/ui/chrome-row"; ``` ```tsx
    1. {message}
    2. ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `CHROME_ROW_CLASS.live` (`chrome-row-live`) | The row that's on stream: a chrome plate, a top line, a rim and a 2 px polished edge down the left | | `CHROME_ROW_CLASS.member` (`chrome-row-member`) | A member's row: a plate at half strength with a 2 px edge in `--member`, so a paid message still reads louder | | `--chrome-row-*` | The plates' stops, rim and hover lift, per theme | Both plates sit on the theme's solid, so the text tiers keep their contrast whatever sits behind. Hover adds a faint lift. They're static: no transition, no motion and no `backdrop-filter`, so a long virtualized chat costs no more than a flat tint. ## Accessibility A plate is colour only. Say what it means in words too: an On stream chip on the live row, a Member chip on a member's row. ## API reference | Prop | Type | Default | Description | | --- | --- | --- | --- | | `CHROME_ROW_CLASS.live` | `"chrome-row-live"` | — | The on-stream row. | | `CHROME_ROW_CLASS.member` | `"chrome-row-member"` | — | A member's row. | # Collapsible (/docs/components/collapsible) ```tsx title="components/examples/collapsible-demo.tsx" "use client"; import { ChevronDownIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; const ENCODER_SETTINGS = [ { label: "Keyframe interval", value: "2 s" }, { label: "B-frames", value: "2" }, { label: "Encoder preset", value: "Quality" }, ]; const CollapsibleDemo = () => ( }> Advanced settings
      {ENCODER_SETTINGS.map((setting) => (
      {setting.label}
      {setting.value}
      ))}
      ); export default CollapsibleDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/collapsible ``` ## Usage ```tsx import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; ``` ```tsx Advanced settings {settings} ``` ## Anatomy ```tsx ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `data-panel-open` | On the trigger while open: turn a chevron with `group-data-panel-open:rotate-180` | | `data-open`, `data-closed` | On the root and the panel | | `--collapsible-panel-height` | The panel's measured height, to animate it | ## Keyboard | Key | Action | | --------------------------------- | ------------------------- | | Enter Space | Opens or closes the panel | ## Accessibility The trigger is a button with `aria-expanded` and `aria-controls`. Render your own button with `render={ No results found. {PAGES.map(({ icon: Icon, label }) => ( setOpen(false)} value={`Open ${label}`} > {label} ))} {selected} ); }; export default CommandDialogExample; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `rounded-panel` | 12 px corners, matching the dialog tier around it | | `text-lg`, `size-6` | The 18 px search field and its 24 px icon, over a hairline | | `min-h-10`, `rounded-row` | 40 px rows with 8 px corners | | `data-selected="true"` | The selected row: the `bg-accent` block, shown instantly | | `text-subtle` | Group headings | | `data-checked="true"` | On an item, shows its check | ## Keyboard | Key | Action | | ---------------------- | ------------------------------- | | Typing | Filters the list | | `ArrowDown`, `ArrowUp` | Moves the selection | | `Home`, `End` | Moves to the first or last item | | `Enter` | Runs the selected item | | `Escape` | Closes the dialog | ## Accessibility The input is a combobox and the list a listbox; the selection is announced as it moves. `CommandDialog` carries a visually hidden title ("Search") and description. Give `CommandInput` an `aria-label` when its placeholder is the only label in view. ## API reference Every [cmdk](https://github.com/pacocoursey/cmdk) prop, plus: ### CommandDialog Every [Base UI Dialog](https://base-ui.com/react/components/dialog) root prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | `"Search"` | The visually hidden title. | | `description` | `string` | `"Search for a command to run…"` | The visually hidden description. | | `showCloseButton` | `boolean` | `false` | — | | `value` | `string` | — | The selected item's value. | | `onValueChange` | `(value: string) => void` | — | — | # Context Menu (/docs/components/context-menu) ```tsx title="components/examples/context-menu-demo.tsx" "use client"; import { CopyIcon, DeleteIcon, EditIcon } from "@/components/icons"; import { ContextMenu, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { ShortcutKeys } from "@/components/ui/shortcut-keys"; const ContextMenuDemo = () => (
      Right-click the scene
      Rename… Duplicate Delete scene…
      ); export default ContextMenuDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/context-menu ``` ## Usage ```tsx import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; ``` ```tsx {sceneCard} Rename… ``` A right-click is a shortcut, never the only way in: every action in a context menu is also in the item's [Dropdown Menu](/docs/components/dropdown-menu) or on screen. ## Anatomy ```tsx ``` ## Examples ### Checkbox items The Stream Manager's stats bar: right-click it to choose which stats show. Checkbox items keep the menu open. ```tsx title="components/examples/context-menu-checkboxes.tsx" "use client"; import { useState } from "react"; import { ContextMenu, ContextMenuCheckboxItem, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; const STATS = [ { id: "viewers", label: "Viewers" }, { id: "bitrate", label: "Bitrate" }, { id: "dropped", label: "Dropped frames" }, { id: "uptime", label: "Uptime" }, ] as const; type StatId = (typeof STATS)[number]["id"]; const DEFAULT_STATS: StatId[] = ["viewers", "bitrate", "uptime"]; const ContextMenuCheckboxes = () => { const [shown, setShown] = useState(DEFAULT_STATS); const toggle = (id: StatId, checked: boolean) => setShown((current) => checked ? [...current, id] : current.filter((entry) => entry !== id) ); return (
      {STATS.filter((stat) => shown.includes(stat.id)).map((stat) => ( {stat.label} ))}
      Stats {STATS.map((stat) => ( toggle(stat.id, checked)} > {stat.label} ))} setShown(DEFAULT_STATS)}> Reset stats
      ); }; export default ContextMenuCheckboxes; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `glass-float` | The popup tier: the window's solid tone, a 1 px rim and the float drop | | `rounded-lg` | The popup tier's 10 px corners, with a 4 px inset | | `min-h-7`, `rounded-chip` | 28 px items with 6 px corners | | `bg-accent` | The highlighted item | | `data-variant="destructive"` | The destructive item's tone | | `select-none` | On the trigger, so a right-click doesn't select its text | ## Keyboard | Key | Action | | ------------------------- | ---------------------------- | | `ArrowDown`, `ArrowUp` | Moves between items | | `ArrowRight`, `ArrowLeft` | Opens or closes a submenu | | `Enter`, `Space` | Chooses the highlighted item | | `Escape` | Closes the menu | ## Accessibility The menu opens from the pointer, so it has no keyboard path of its own; that is why its actions live somewhere else too. Like Dropdown Menu, it stops wheel events at its edge, and a `ContextMenuLabel` outside any group is a plain heading row. ## API reference Every [Base UI Context Menu](https://base-ui.com/react/components/context-menu) prop, plus: ### ContextMenuContent | Prop | Type | Default | Description | | --- | --- | --- | --- | | `side` | `"top" \| "right" \| "bottom" \| "left"` | `"right"` | — | | `sideOffset` | `number` | `0` | — | | `align` | `"start" \| "center" \| "end"` | `"start"` | — | | `alignOffset` | `number` | `4` | — | ### ContextMenuItem | Prop | Type | Default | Description | | --- | --- | --- | --- | | `variant` | `"default" \| "destructive"` | `"default"` | — | # Dialog (/docs/components/dialog) ```tsx title="components/examples/dialog-demo.tsx" "use client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; const DialogDemo = () => ( }> Rename scene… Rename scene The new name shows in the Studio and in Scene. Name }>Cancel }>Save ); export default DialogDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/dialog ``` ## Usage ```tsx import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; ``` ```tsx }> Rename scene… Rename scene The new name shows in the Studio. }>Cancel ``` Size a dialog with `size`, never a `max-w-*` or `max-h-*` of its own. ## Anatomy ```tsx ``` ## Examples ### Sizes `sm` (384 px) for a rename or one question, `md` (448 px, the default) for confirmations and short forms, `lg` (576 px) for forms, pickers and setup, `xl` (768 px) for review and media. ```tsx title="components/examples/dialog-sizes.tsx" "use client"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import type { DialogSize } from "@/components/ui/dialog"; const SIZES: { size: DialogSize; title: string; use: string }[] = [ { size: "sm", title: "Rename scene", use: "384 px: a rename, one question." }, { size: "md", title: "Delete saved scene?", use: "448 px, the default: confirmations and short forms.", }, { size: "lg", title: "Set up your microphone", use: "576 px: forms, pickers and setup.", }, { size: "xl", title: "Go Live", use: "768 px: review and media, such as Go Live and markers.", }, ]; const DialogSizes = () => (
      {SIZES.map(({ size, title, use }) => ( }> {size} {title} {use} ))}
      ); export default DialogSizes; ``` ### Scrolling body A dialog is never taller than the window less 16 px a side. Put long content in a `DialogBody`: it takes the height the header and footer leave and scrolls, so they stay in view. A dialog without one scrolls whole. ```tsx title="components/examples/dialog-body.tsx" "use client"; import { MarkerIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; const MARKERS = Array.from({ length: 24 }, (_, index) => ({ id: `marker-${index + 1}`, label: `Marker ${index + 1}`, time: `0:${String(index * 2 + 3).padStart(2, "0")}:12`, })); const DialogBodyExample = () => ( }> Review markers… Markers 24 markers in this recording. Jump to one to review it.
        {MARKERS.map((marker) => (
      • {marker.label} {marker.time}
      • ))}
      }>Done
      ); export default DialogBodyExample; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `glass-float-dialog` | The dialog tier: a step brighter than the popup tier, with its own drop | | `rounded-panel`, `p-5`, `gap-5` | 12 px corners and the dialog's spacing | | `max-h-[calc(100vh-2rem)]` | The height rule | | `data-size` | `sm`, `md`, `lg` or `xl` | | `bg-black/30` | The overlay, with no backdrop blur: blur wedged the compositor on a vibrancy window | | `data-open`, `data-closed` | Fades and scales from 0.98 in 100 ms | `dialogContentVariants({ size })` returns the dialog's classes, `DIALOG_SURFACE_CLASS` the surface alone (for a card on the dialog tier that isn't a dialog), and `DIALOG_SIZE_CLASS` the widths. ## Keyboard | Key | Action | | ------------------ | ------------------------------------------------------- | | `Tab`, `Shift+Tab` | Moves through the dialog's controls; focus stays inside | | `Escape` | Closes the dialog | ## Accessibility `DialogTitle` names the dialog and `DialogDescription` describes it. Focus moves to the first control on open (choose another with `initialFocus`) and back to the trigger on close. The close button reads "Close". ## API reference Every [Base UI Dialog](https://base-ui.com/react/components/dialog) prop, plus: ### DialogContent | Prop | Type | Default | Description | | --- | --- | --- | --- | | `size` | `"sm" \| "md" \| "lg" \| "xl"` | `"md"` | — | | `showCloseButton` | `boolean` | `true` | The ghost close button, top right. | ### DialogFooter | Prop | Type | Default | Description | | --- | --- | --- | --- | | `showCloseButton` | `boolean` | `false` | Adds an outline Close button. | # Disabled Reason (/docs/components/disabled-reason) ```tsx title="components/examples/disabled-reason-demo.tsx" "use client"; import { Button } from "@/components/ui/button"; import { DisabledReason } from "@/components/ui/disabled-reason"; const DisabledReasonDemo = () => (
      ); export default DisabledReasonDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/disabled-reason ``` ## Usage ```tsx import { DisabledReason } from "@/components/ui/disabled-reason"; ``` ```tsx ``` Pass the reason only while the control is disabled. With no reason the control renders exactly as it is, with no extra tab stop. ## Examples ### In a menu A menu or select item is reachable while disabled: Base UI keeps it in the arrow-key order as `aria-disabled`, and the highlight lands on it. Pass `focusable={false}` and the item itself takes the hover and the focus and is described, with no wrapper and no second stop in the list. ```tsx title="components/examples/disabled-reason-menu.tsx" "use client"; import { MoreIcon } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { DisabledReason } from "@/components/ui/disabled-reason"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; const DisabledReasonMenu = () => ( } > Rename… Show in Finder Delete ); export default DisabledReasonMenu; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `rounded-chip`, `focus-visible:ring-3` | The wrapper's focus ring, around the control | | `data-disabled:pointer-events-auto` | Added to a menu or select item with `focusable={false}`, so it takes the hover | | `data-slot="disabled-reason"` | The focusable wrapper | | `data-slot="disabled-reason-text"` | The hidden description | ## Keyboard | Key | Action | | --- | --- | | Tab | Focuses the wrapper of a disabled control and shows the reason | | ↑ ↓ | In a menu, highlight a disabled item and show its reason | | Esc | Hides the reason | ## Accessibility A disabled control takes no pointer events and no focus, so a `title` on it never shows. The wrapper is a tab stop with the reason as its `aria-describedby`, kept in the DOM, hidden, not only while the tooltip shows. Use `label` when the control's own name isn't enough to say what the wrapper stands for. Never pass `focusable={false}` for a native disabled ` ); export default EmptyDemo; ``` ## Installation ```npm npx shadcn@latest add @videorc/empty ``` ## Usage ```tsx import { Empty, EmptyDescription, EmptyHeader, EmptyTitle, } from "@/components/ui/empty"; ``` ```tsx No recordings yet Recordings you make in the Studio show up here. ``` ## Anatomy ```tsx ``` ## Examples ### Description only When a list is empty only because of a filter or a search, the description alone is enough. The pane or list it sits in sets the space around it. ```tsx title="components/examples/empty-description.tsx" "use client"; import { Empty, EmptyDescription, EmptyHeader } from "@/components/ui/empty"; const EmptyDescriptionExample = () => (
      No messages match these filters.
      ); export default EmptyDescriptionExample; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `text-sm`, `text-muted-foreground` | The description: 14 px secondary text | | `text-sm font-medium` | The title: 14 px at weight 500, never larger | | `gap-3`, `p-6` | The only spacing it brings; it has no radius or border of its own | ## Accessibility `EmptyTitle` is a `
      `. When the empty state is what the page is about, put a heading inside it (`

      …

      `). An action in `EmptyContent` is an ordinary button or link with its own name. ## API reference Every part takes every `
      ` prop. Unlike shadcn/ui's Empty there is no `EmptyMedia`: Videorc's empty states are words only. # Fader (/docs/components/fader) ```tsx title="components/examples/fader-demo.tsx" "use client"; import { useState } from "react"; import { Fader, FaderLabel, FaderRange, FaderReset, FaderScale, FaderThumb, FaderTrack, FaderValue, } from "@/components/ui/fader"; const FaderDemo = () => { const [volumeDb, setVolumeDb] = useState(-6); return (
      Microphone
      ); }; export default FaderDemo; ``` This is a fork of [audiocn's Fader](https://audiocn.dev/docs/components/fader) (MIT). Its behaviour, parts and props are upstream's; only the paint differs: - The range is `chrome-fill`, not `bg-primary`. - The default thumb is a `chrome-bead` on the knob's hairline rim (`ring-knob-ring`), like every Videorc slider. - The console thumb's cap is `rounded-chip`, and the value, its editor and Reset sit on `rounded-row`. ## Installation ```npm npx shadcn@latest add @videorc/fader ``` The fader builds on audiocn's core (`@audiocn/core`, `@audiocn/use-audio-config` and `@audiocn/db-scale`), so add the `@audiocn` namespace to `components.json` as well: ```json title="components.json" { "registries": { "@videorc": "https://ui.videorc.com/r/{name}.json", "@audiocn": "https://audiocn.dev/r/{name}.json" } } ``` ## Usage ```tsx import { Fader, FaderLabel, FaderRange, FaderThumb, FaderTrack, FaderValue, } from "@/components/ui/fader"; ``` ```tsx const [volumeDb, setVolumeDb] = useState(0); Microphone ; ``` The value is always in dB. The taper decides where each value sits on the travel. The thumb snaps to its detents (0 dB by default) while dragging, and double-clicking it resets. ## Anatomy ```tsx ``` `FaderTrack` takes any children, so a level meter can sit inside it. ## Examples ### Bipolar gain Videorc's Gain faders: `origin={0}` grows the fill from the middle, whole-dB steps, a detent at 0 dB and a value you can type into. ```tsx title="components/examples/fader-gain.tsx" "use client"; import { useState } from "react"; import { Fader, FaderLabel, FaderRange, FaderReset, FaderScale, FaderThumb, FaderTrack, FaderValue, } from "@/components/ui/fader"; const GAIN_DETENTS = [0]; const GAIN_TICKS = [-24, -12, 0, 12, 24]; const FaderGain = () => { const [gainDb, setGainDb] = useState(0); return (
      Microphone gain
      ); }; export default FaderGain; ``` ### Console faders Vertical faders with `variant="console"` and `taper="audio"`, which gives more travel around 0 dB, like a mixing console. ```tsx title="components/examples/fader-console.tsx" "use client"; import { useState } from "react"; import { Fader, FaderLabel, FaderRange, FaderScale, FaderThumb, FaderTrack, FaderValue, } from "@/components/ui/fader"; const CHANNELS = ["Mic", "System", "Camera"] as const; type Channel = (typeof CHANNELS)[number]; const FaderConsole = () => { const [levels, setLevels] = useState>({ Camera: -18, Mic: 0, System: -12, }); return (
      {CHANNELS.map((channel) => ( setLevels((current) => ({ ...current, [channel]: value })) } orientation="vertical" taper="audio" value={levels[channel]} variant="console" >
      {channel}
      ))}
      ); }; export default FaderConsole; ``` ## Theming | Token or attribute | Meaning | | --- | --- | | `chrome-fill` | The range | | `bg-input/90` | The track | | `chrome-bead` | The default thumb | | `ring-knob-ring` | The default thumb's hairline rim; a 4 px ring on hover, focus and drag | | `rounded-chip` | The console thumb's cap | | `rounded-row` | The value, its editor and Reset | | `--fader-track-size`, `--fader-thumb-size` | Set by `size` | | `data-orientation`, `data-variant`, `data-size` | The current settings | | `data-dragging` | The thumb is held | | `data-at-detent` | The value sits on a detent | | `data-silent` | The value is `-Infinity` | | `data-disabled` | Dims to 50% | | `data-slot` | `fader`, `fader-label`, `fader-control`, `fader-track`, `fader-range`, `fader-thumb`, `fader-scale`, `fader-value`, `fader-value-input`, `fader-reset` | ## Keyboard | Key | Action | | --- | --- | | ← → ↑ ↓ | Changes the value by `step` | | Shift with an arrow, Page Up Page Down | Changes it by `largeStep` | | Alt with an arrow | Changes it by `fineStep` | | Home End | Moves to the minimum (or silence) or the maximum | Holding Alt while dragging uses `fineStep` too. ## Accessibility The thumb holds a native range input with `role="slider"`, and its `aria-valuetext` is the formatted value, such as "−6.0 dB" or "Silent". Name the fader with `FaderLabel` or `aria-label`. The editable value's input is named "Value in dB". ## API reference ### Fader Every [Base UI Slider](https://base-ui.com/react/components/slider) root prop, plus: | Prop | Type | Default | Description | | --- | --- | --- | --- | | `value` | `number` | — | The value in dB. | | `defaultValue` | `number` | `resetValue` | — | | `onValueChange` | `(value, details) => void` | — | details.reason is drag, track-press, keyboard, wheel, reset or input. | | `onValueCommitted` | `(value) => void` | — | Fires when a drag ends, after keyboard input and on reset. | | `min` | `number` | `-60` | — | | `max` | `number` | `6` | — | | `step` | `number` | `0.5` | Arrow keys and drag resolution. | | `largeStep` | `number` | `6` | Shift with an arrow, Page Up and Page Down. | | `fineStep` | `number` | `0.1` | Alt with an arrow, and Alt while dragging. | | `resetValue` | `number` | `0` | Restored by double-click and FaderReset. | | `taper` | `"linear" \| "audio" \| Taper` | `"linear"` | The position law. | | `origin` | `number` | `min` | Where the range fill starts. | | `detents` | `number[]` | `[0]` | Values the thumb snaps to while dragging. | | `silenceAtMin` | `boolean` | `false` | The lowest position reports -Infinity. | | `allowWheel` | `boolean` | `false` | The wheel changes the value while the fader is focused. | | `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | — | | `variant` | `"default" \| "console"` | `"default"` | console has a wide cap thumb. | | `size` | `"sm" \| "default" \| "lg"` | `"default"` | — | | `format` | `(db: number) => string` | — | Used by FaderValue and assistive technology. | | `disabled` | `boolean` | `false` | — | ### FaderValue The value is as wide as the longest text the fader's range can show, and right-aligned, so moving the fader never shifts the layout. The editor opens at the same width. | Prop | Type | Default | Description | | --- | --- | --- | --- | | `editable` | `boolean` | `false` | Click to type a value. Accepts "-6", "−6 dB" and "-inf". | ### FaderScale Takes [audiocn's DbScale](https://audiocn.dev/docs/components/db-scale) props. The range, taper and orientation come from the fader. # Field (/docs/components/field) ```tsx title="components/examples/field-demo.tsx" "use client"; import { useState } from "react"; import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; const TITLE_MAX = 100; const FieldDemo = () => { const [title, setTitle] = useState("Friday night speedruns"); return (
      Stream details
      Title {title.length}/{TITLE_MAX}
      setTitle(event.target.value)} placeholder="Untitled livestream" value={title} />
      Description