Desktop 3: software update
The update window of a desktop app, the whole cycle: checking, a new version with release notes, downloading with progress, ready to relaunch, up to date and failed. Buttons follow each platform's order and wording.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Desktop 3. https://ui.ballmac.com/blocks/desktop-3
"use client"
import * as React from "react"
import { CircleAlert, CircleCheck, Download, RotateCw, Sparkles } from "lucide-react"
import { AppWindow, AppWindowContent, AppWindowTitleBar } from "@/components/ballmac/app-window"
import { Button } from "@/components/ballmac/button"
import { Checkbox } from "@/components/ballmac/checkbox"
import { Progress } from "@/components/ballmac/progress"
import { Spinner } from "@/components/ballmac/spinner"
import { frameProps, type ErrorConfig } from "@/components/ballmac/state-view"
import { useFeel, type FeelId } from "@/lib/ballmac/feel"
import type { BlockState } from "@/lib/ballmac/use-resource"
import { cn } from "@/lib/utils"
type Desktop3Release = {
version: string
/** Already written for people ("12 October 2026"). */
date?: string
changes: { type?: "new" | "improved" | "fixed"; text: string }[]
}
type Desktop3Props = Omit<React.ComponentProps<"div">, "children"> & {
/** Which platform the window handles like. Defaults to the page's feel. */
feel?: FeelId
/** Draw the window frame. Turn off inside a real Tauri or Electron window that already has one. */
frame?: boolean
appName?: string
/** The version running now. */
currentVersion?: string
/** The releases since the current version, newest first. The first one is the version offered. */
releases?: Desktop3Release[]
/** The download size in bytes, shown while downloading. */
size?: number
/** The app icon. A generated one is drawn when you leave it out. */
icon?: React.ReactNode
/**
* Where the update is. Leave it out and the window runs the whole flow itself (offer, download with progress, ready to relaunch).
* `loading` is checking, `empty` is up to date, `submitting` is downloading (with `progress`), `success` is ready to relaunch, `error` failed.
*/
state?: BlockState
/** Download progress from 0 to 1 while `state` is `submitting`. */
progress?: number
/** Copy for the error state. */
errorState?: ErrorConfig
/** Whether "Automatically download and install updates" is ticked (controlled). */
automatic?: boolean
onAutomaticChange?: (automatic: boolean) => void
/**
* Called by Install. Report progress from 0 to 1 with `report`; resolve when the update is downloaded and ready, throw to show the
* error. Defaults to a short simulated download.
*/
onInstall?: (report: (progress: number) => void) => void | Promise<void>
/** Called by "Install and relaunch" once the update is ready. */
onRelaunch?: () => void
/** Called by "Remind me later" and Close. */
onRemindLater?: () => void
/** Called by "Skip this version" with the version skipped. */
onSkip?: (version: string) => void
/** Called by "Try again" in the error state, and by "Check again" when up to date. */
onRetry?: () => void
}
const sample: Desktop3Release[] = [
{
version: "2.5.0",
date: "14 October 2026",
changes: [
{ type: "new", text: "Folders can be shared with a link that expires." },
{ type: "new", text: "Quick look: press Space on any item to preview it." },
{ type: "improved", text: "Search is up to three times faster in large libraries." },
{ type: "fixed", text: "The window remembers its size on displays with different scaling." },
],
},
{
version: "2.4.2",
date: "2 October 2026",
changes: [{ type: "fixed", text: "Sync no longer pauses after the computer wakes from sleep." }],
},
]
const changeLabel = { new: "New", improved: "Improved", fixed: "Fixed" } as const
const mb = (bytes: number) => `${(bytes / 1_000_000).toFixed(1)} MB`
function AppIcon({ icon, className }: { icon?: React.ReactNode; className?: string }) {
return (
<span
aria-hidden="true"
className={cn(
"from-primary to-primary/70 text-primary-foreground flex shrink-0 items-center justify-center rounded-[22%] bg-linear-to-b shadow-[0_1px_2px_rgb(0_0_0/0.2),inset_0_1px_0_rgb(255_255_255/0.25)] [&_svg]:size-1/2",
className
)}
>
{icon ?? <Sparkles />}
</span>
)
}
/** The buttons along the bottom, in the platform's order: macOS puts the default button last, Windows first. */
function Actions({ feel, start, children }: { feel: FeelId; start?: React.ReactNode; children: React.ReactNode[] }) {
const ordered = feel === "windows" ? [...children].reverse() : children
return (
<div data-slot="desktop-3-actions" className="flex flex-wrap items-center gap-2">
{start}
<div className="ms-auto flex flex-wrap justify-end gap-2">{ordered}</div>
</div>
)
}
function Desktop3({
feel: feelProp,
frame = true,
appName = "Acme",
currentVersion = "2.4.1",
releases = sample,
size = 42_600_000,
icon,
state: stateProp,
progress: progressProp,
errorState,
automatic: automaticProp,
onAutomaticChange,
onInstall,
onRelaunch,
onRemindLater,
onSkip,
onRetry,
className,
...props
}: Desktop3Props) {
const page = useFeel().feel
const feel = feelProp ?? page
const win = feel === "windows"
const id = React.useId()
const [internal, setInternal] = React.useState<BlockState>("ready")
const [internalProgress, setInternalProgress] = React.useState(0)
const [autoLocal, setAutoLocal] = React.useState(false)
const state = stateProp ?? internal
const progress = Math.min(1, Math.max(0, progressProp ?? internalProgress))
const automatic = automaticProp ?? autoLocal
const offered = releases[0]
const mounted = React.useRef(true)
React.useEffect(() => () => void (mounted.current = false), [])
async function install() {
setInternal("submitting")
setInternalProgress(0)
const report = (p: number) => mounted.current && setInternalProgress(p)
try {
if (onInstall) await onInstall(report)
else
for (let i = 1; i <= 20; i++) {
await new Promise((r) => setTimeout(r, 90))
report(i / 20)
}
if (mounted.current) setInternal("success")
} catch {
if (mounted.current) setInternal("error")
}
}
const heading =
state === "loading"
? "Checking for updates…"
: state === "empty" || !offered
? `${appName} is up to date`
: state === "error"
? "The update could not be downloaded"
: state === "submitting"
? `Downloading ${appName} ${offered.version}…`
: state === "success"
? `${appName} ${offered.version} is ready to install`
: `A new version of ${appName} is available`
const detail =
state === "loading"
? `You have ${appName} ${currentVersion}.`
: state === "empty" || !offered
? `${currentVersion} is the newest version.`
: state === "error"
? (errorState?.description ?? "Check your internet connection and try again. Nothing was changed.")
: state === "submitting"
? `${mb(size * progress)} of ${mb(size)}`
: state === "success"
? `${appName} will quit, install the update and open again. Unsaved work is kept.`
: `${appName} ${offered.version} is available. You have ${currentVersion}.`
const showNotes = offered && (state === "ready" || state === "submitting" || state === "success")
const status = state === "loading" ? <Spinner className="size-4" /> : state === "error" ? <CircleAlert className="text-destructive size-4" /> : state === "empty" || state === "success" ? <CircleCheck className="text-chart-2 size-4" /> : null
const content = (
<div data-slot="desktop-3-body" className="flex min-h-0 flex-1 flex-col gap-4 p-5">
<div className={cn("flex gap-4", win ? "items-center" : "items-start")}>
<AppIcon icon={icon} className={win ? "size-10" : "size-16"} />
<div className="min-w-0 flex-1">
{/* only the heading is live: the byte count under it changes many times a second */}
<h2 aria-live="polite" aria-atomic="true" id={`${id}-title`} className={cn("flex items-center gap-2 font-semibold text-pretty", win ? "text-lg" : "text-[15px]")}>
{status ? <span aria-hidden="true">{status}</span> : null}
{errorState?.title && state === "error" ? errorState.title : heading}
</h2>
<p className="text-muted-foreground mt-1 text-[13px] tabular-nums text-pretty">{detail}</p>
</div>
</div>
{state === "submitting" ? <Progress value={progress * 100} aria-label={`Downloading ${appName} ${offered?.version ?? ""}`} /> : null}
{showNotes ? (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<p className="text-[13px] font-medium">{win ? "What’s new" : "Release notes:"}</p>
<div tabIndex={0} aria-label="Release notes" role="region" className="bg-background min-h-0 flex-1 overflow-y-auto rounded-md border p-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
{releases.map((r) => (
<section key={r.version} className="mb-4 last:mb-0">
<h3 className="text-sm font-semibold">
{appName} {r.version}
{r.date ? <span className="text-muted-foreground ms-2 text-xs font-normal">{r.date}</span> : null}
</h3>
<ul className="mt-2 grid gap-1.5">
{r.changes.map((c, i) => (
<li key={i} className="flex gap-2 text-[13px]">
{c.type ? (
<span
className={cn(
"mt-px h-fit shrink-0 rounded px-1.5 text-[10px] leading-4 font-medium uppercase",
c.type === "new" ? "bg-primary/10 text-primary" : c.type === "fixed" ? "bg-chart-2/15 text-foreground" : "bg-muted text-foreground"
)}
>
{changeLabel[c.type]}
</span>
) : null}
<span className="text-pretty">{c.text}</span>
</li>
))}
</ul>
</section>
))}
</div>
</div>
) : (
<div className="flex-1" />
)}
{state === "ready" && offered ? (
<div className="flex items-center gap-2">
<Checkbox
id={`${id}-auto`}
checked={automatic}
onCheckedChange={(c) => {
setAutoLocal(c === true)
onAutomaticChange?.(c === true)
}}
/>
<label htmlFor={`${id}-auto`} className="text-[13px]">
Automatically download and install updates in the future
</label>
</div>
) : null}
{state === "ready" && offered ? (
<Actions
feel={feel}
start={
<Button variant={win ? "link" : "outline"} size="sm" className={cn(win && "px-0")} onClick={() => onSkip?.(offered.version)}>
{win ? "Skip this version" : "Skip This Version"}
</Button>
}
>
{[
<Button key="later" variant="outline" size="sm" onClick={onRemindLater}>
{win ? "Remind me later" : "Remind Me Later"}
</Button>,
<Button key="install" size="sm" onClick={install}>
<Download aria-hidden="true" />
{win ? "Install now" : "Install Update"}
</Button>,
]}
</Actions>
) : state === "submitting" ? (
<Actions feel={feel}>
{[
<Button key="hide" variant="outline" size="sm" onClick={onRemindLater}>
Hide
</Button>,
]}
</Actions>
) : state === "success" ? (
<Actions feel={feel}>
{[
<Button key="later" variant="outline" size="sm" onClick={onRemindLater}>
{win ? "Install on quit" : "Install on Quit"}
</Button>,
<Button key="relaunch" size="sm" onClick={onRelaunch}>
<RotateCw aria-hidden="true" />
{win ? "Restart now" : "Install and Relaunch"}
</Button>,
]}
</Actions>
) : state === "error" ? (
<Actions feel={feel}>
{[
<Button key="close" variant="outline" size="sm" onClick={onRemindLater}>
{win ? "Close" : "Cancel"}
</Button>,
<Button key="retry" size="sm" onClick={onRetry ?? (stateProp === undefined ? install : undefined)}>
{errorState?.retryLabel ?? (win ? "Try again" : "Try Again")}
</Button>,
]}
</Actions>
) : state === "empty" || !offered ? (
<Actions feel={feel}>
{[
<Button key="ok" size="sm" onClick={onRemindLater}>
OK
</Button>,
]}
</Actions>
) : (
<Actions feel={feel}>
{[
<Button key="cancel" variant="outline" size="sm" onClick={onRemindLater}>
Cancel
</Button>,
]}
</Actions>
)}
</div>
)
const busy = state === "loading" || state === "submitting"
if (!frame)
return (
<div data-slot="desktop-3" data-state={state} data-feel={feelProp} aria-busy={busy || undefined} aria-labelledby={`${id}-title`} role="region" className={cn("flex flex-col", className)} {...frameProps(props)}>
{content}
</div>
)
return (
<AppWindow
data-slot="desktop-3"
data-state={state}
feel={feel}
onClose={onRemindLater}
aria-busy={busy || undefined}
aria-labelledby={`${id}-title`}
role="region"
className={cn("h-[28rem] w-full max-w-xl", className)}
{...frameProps(props)}
>
<AppWindowTitleBar title={win ? `${appName} update` : "Software Update"} icon={<RotateCw />} className="border-b-0" />
<AppWindowContent className="flex flex-col">{content}</AppWindowContent>
</AppWindow>
)
}
export { Desktop3, type Desktop3Props, type Desktop3Release }Installation
$ pnpm dlx shadcn@latest add @ballmac/desktop-3Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/app-window @ballmac/button @ballmac/checkbox @ballmac/progress @ballmac/spinner @ballmac/state-view @ballmac/use-resource @ballmac/feel-presetsCopy the source into your project.
components/ballmac/blocks/desktop-3/desktop-3.tsx// Ballmac UI: Desktop 3. https://ui.ballmac.com/blocks/desktop-3 "use client" import * as React from "react" import { CircleAlert, CircleCheck, Download, RotateCw, Sparkles } from "lucide-react" import { AppWindow, AppWindowContent, AppWindowTitleBar } from "@/components/ballmac/app-window" import { Button } from "@/components/ballmac/button" import { Checkbox } from "@/components/ballmac/checkbox" import { Progress } from "@/components/ballmac/progress" import { Spinner } from "@/components/ballmac/spinner" import { frameProps, type ErrorConfig } from "@/components/ballmac/state-view" import { useFeel, type FeelId } from "@/lib/ballmac/feel" import type { BlockState } from "@/lib/ballmac/use-resource" import { cn } from "@/lib/utils" type Desktop3Release = { version: string /** Already written for people ("12 October 2026"). */ date?: string changes: { type?: "new" | "improved" | "fixed"; text: string }[] } type Desktop3Props = Omit<React.ComponentProps<"div">, "children"> & { /** Which platform the window handles like. Defaults to the page's feel. */ feel?: FeelId /** Draw the window frame. Turn off inside a real Tauri or Electron window that already has one. */ frame?: boolean appName?: string /** The version running now. */ currentVersion?: string /** The releases since the current version, newest first. The first one is the version offered. */ releases?: Desktop3Release[] /** The download size in bytes, shown while downloading. */ size?: number /** The app icon. A generated one is drawn when you leave it out. */ icon?: React.ReactNode /** * Where the update is. Leave it out and the window runs the whole flow itself (offer, download with progress, ready to relaunch). * `loading` is checking, `empty` is up to date, `submitting` is downloading (with `progress`), `success` is ready to relaunch, `error` failed. */ state?: BlockState /** Download progress from 0 to 1 while `state` is `submitting`. */ progress?: number /** Copy for the error state. */ errorState?: ErrorConfig /** Whether "Automatically download and install updates" is ticked (controlled). */ automatic?: boolean onAutomaticChange?: (automatic: boolean) => void /** * Called by Install. Report progress from 0 to 1 with `report`; resolve when the update is downloaded and ready, throw to show the * error. Defaults to a short simulated download. */ onInstall?: (report: (progress: number) => void) => void | Promise<void> /** Called by "Install and relaunch" once the update is ready. */ onRelaunch?: () => void /** Called by "Remind me later" and Close. */ onRemindLater?: () => void /** Called by "Skip this version" with the version skipped. */ onSkip?: (version: string) => void /** Called by "Try again" in the error state, and by "Check again" when up to date. */ onRetry?: () => void } const sample: Desktop3Release[] = [ { version: "2.5.0", date: "14 October 2026", changes: [ { type: "new", text: "Folders can be shared with a link that expires." }, { type: "new", text: "Quick look: press Space on any item to preview it." }, { type: "improved", text: "Search is up to three times faster in large libraries." }, { type: "fixed", text: "The window remembers its size on displays with different scaling." }, ], }, { version: "2.4.2", date: "2 October 2026", changes: [{ type: "fixed", text: "Sync no longer pauses after the computer wakes from sleep." }], }, ] const changeLabel = { new: "New", improved: "Improved", fixed: "Fixed" } as const const mb = (bytes: number) => `${(bytes / 1_000_000).toFixed(1)} MB` function AppIcon({ icon, className }: { icon?: React.ReactNode; className?: string }) { return ( <span aria-hidden="true" className={cn( "from-primary to-primary/70 text-primary-foreground flex shrink-0 items-center justify-center rounded-[22%] bg-linear-to-b shadow-[0_1px_2px_rgb(0_0_0/0.2),inset_0_1px_0_rgb(255_255_255/0.25)] [&_svg]:size-1/2", className )} > {icon ?? <Sparkles />} </span> ) } /** The buttons along the bottom, in the platform's order: macOS puts the default button last, Windows first. */ function Actions({ feel, start, children }: { feel: FeelId; start?: React.ReactNode; children: React.ReactNode[] }) { const ordered = feel === "windows" ? [...children].reverse() : children return ( <div data-slot="desktop-3-actions" className="flex flex-wrap items-center gap-2"> {start} <div className="ms-auto flex flex-wrap justify-end gap-2">{ordered}</div> </div> ) } function Desktop3({ feel: feelProp, frame = true, appName = "Acme", currentVersion = "2.4.1", releases = sample, size = 42_600_000, icon, state: stateProp, progress: progressProp, errorState, automatic: automaticProp, onAutomaticChange, onInstall, onRelaunch, onRemindLater, onSkip, onRetry, className, ...props }: Desktop3Props) { const page = useFeel().feel const feel = feelProp ?? page const win = feel === "windows" const id = React.useId() const [internal, setInternal] = React.useState<BlockState>("ready") const [internalProgress, setInternalProgress] = React.useState(0) const [autoLocal, setAutoLocal] = React.useState(false) const state = stateProp ?? internal const progress = Math.min(1, Math.max(0, progressProp ?? internalProgress)) const automatic = automaticProp ?? autoLocal const offered = releases[0] const mounted = React.useRef(true) React.useEffect(() => () => void (mounted.current = false), []) async function install() { setInternal("submitting") setInternalProgress(0) const report = (p: number) => mounted.current && setInternalProgress(p) try { if (onInstall) await onInstall(report) else for (let i = 1; i <= 20; i++) { await new Promise((r) => setTimeout(r, 90)) report(i / 20) } if (mounted.current) setInternal("success") } catch { if (mounted.current) setInternal("error") } } const heading = state === "loading" ? "Checking for updates…" : state === "empty" || !offered ? `${appName} is up to date` : state === "error" ? "The update could not be downloaded" : state === "submitting" ? `Downloading ${appName} ${offered.version}…` : state === "success" ? `${appName} ${offered.version} is ready to install` : `A new version of ${appName} is available` const detail = state === "loading" ? `You have ${appName} ${currentVersion}.` : state === "empty" || !offered ? `${currentVersion} is the newest version.` : state === "error" ? (errorState?.description ?? "Check your internet connection and try again. Nothing was changed.") : state === "submitting" ? `${mb(size * progress)} of ${mb(size)}` : state === "success" ? `${appName} will quit, install the update and open again. Unsaved work is kept.` : `${appName} ${offered.version} is available. You have ${currentVersion}.` const showNotes = offered && (state === "ready" || state === "submitting" || state === "success") const status = state === "loading" ? <Spinner className="size-4" /> : state === "error" ? <CircleAlert className="text-destructive size-4" /> : state === "empty" || state === "success" ? <CircleCheck className="text-chart-2 size-4" /> : null const content = ( <div data-slot="desktop-3-body" className="flex min-h-0 flex-1 flex-col gap-4 p-5"> <div className={cn("flex gap-4", win ? "items-center" : "items-start")}> <AppIcon icon={icon} className={win ? "size-10" : "size-16"} /> <div className="min-w-0 flex-1"> {/* only the heading is live: the byte count under it changes many times a second */} <h2 aria-live="polite" aria-atomic="true" id={`${id}-title`} className={cn("flex items-center gap-2 font-semibold text-pretty", win ? "text-lg" : "text-[15px]")}> {status ? <span aria-hidden="true">{status}</span> : null} {errorState?.title && state === "error" ? errorState.title : heading} </h2> <p className="text-muted-foreground mt-1 text-[13px] tabular-nums text-pretty">{detail}</p> </div> </div> {state === "submitting" ? <Progress value={progress * 100} aria-label={`Downloading ${appName} ${offered?.version ?? ""}`} /> : null} {showNotes ? ( <div className="flex min-h-0 flex-1 flex-col gap-2"> <p className="text-[13px] font-medium">{win ? "What’s new" : "Release notes:"}</p> <div tabIndex={0} aria-label="Release notes" role="region" className="bg-background min-h-0 flex-1 overflow-y-auto rounded-md border p-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"> {releases.map((r) => ( <section key={r.version} className="mb-4 last:mb-0"> <h3 className="text-sm font-semibold"> {appName} {r.version} {r.date ? <span className="text-muted-foreground ms-2 text-xs font-normal">{r.date}</span> : null} </h3> <ul className="mt-2 grid gap-1.5"> {r.changes.map((c, i) => ( <li key={i} className="flex gap-2 text-[13px]"> {c.type ? ( <span className={cn( "mt-px h-fit shrink-0 rounded px-1.5 text-[10px] leading-4 font-medium uppercase", c.type === "new" ? "bg-primary/10 text-primary" : c.type === "fixed" ? "bg-chart-2/15 text-foreground" : "bg-muted text-foreground" )} > {changeLabel[c.type]} </span> ) : null} <span className="text-pretty">{c.text}</span> </li> ))} </ul> </section> ))} </div> </div> ) : ( <div className="flex-1" /> )} {state === "ready" && offered ? ( <div className="flex items-center gap-2"> <Checkbox id={`${id}-auto`} checked={automatic} onCheckedChange={(c) => { setAutoLocal(c === true) onAutomaticChange?.(c === true) }} /> <label htmlFor={`${id}-auto`} className="text-[13px]"> Automatically download and install updates in the future </label> </div> ) : null} {state === "ready" && offered ? ( <Actions feel={feel} start={ <Button variant={win ? "link" : "outline"} size="sm" className={cn(win && "px-0")} onClick={() => onSkip?.(offered.version)}> {win ? "Skip this version" : "Skip This Version"} </Button> } > {[ <Button key="later" variant="outline" size="sm" onClick={onRemindLater}> {win ? "Remind me later" : "Remind Me Later"} </Button>, <Button key="install" size="sm" onClick={install}> <Download aria-hidden="true" /> {win ? "Install now" : "Install Update"} </Button>, ]} </Actions> ) : state === "submitting" ? ( <Actions feel={feel}> {[ <Button key="hide" variant="outline" size="sm" onClick={onRemindLater}> Hide </Button>, ]} </Actions> ) : state === "success" ? ( <Actions feel={feel}> {[ <Button key="later" variant="outline" size="sm" onClick={onRemindLater}> {win ? "Install on quit" : "Install on Quit"} </Button>, <Button key="relaunch" size="sm" onClick={onRelaunch}> <RotateCw aria-hidden="true" /> {win ? "Restart now" : "Install and Relaunch"} </Button>, ]} </Actions> ) : state === "error" ? ( <Actions feel={feel}> {[ <Button key="close" variant="outline" size="sm" onClick={onRemindLater}> {win ? "Close" : "Cancel"} </Button>, <Button key="retry" size="sm" onClick={onRetry ?? (stateProp === undefined ? install : undefined)}> {errorState?.retryLabel ?? (win ? "Try again" : "Try Again")} </Button>, ]} </Actions> ) : state === "empty" || !offered ? ( <Actions feel={feel}> {[ <Button key="ok" size="sm" onClick={onRemindLater}> OK </Button>, ]} </Actions> ) : ( <Actions feel={feel}> {[ <Button key="cancel" variant="outline" size="sm" onClick={onRemindLater}> Cancel </Button>, ]} </Actions> )} </div> ) const busy = state === "loading" || state === "submitting" if (!frame) return ( <div data-slot="desktop-3" data-state={state} data-feel={feelProp} aria-busy={busy || undefined} aria-labelledby={`${id}-title`} role="region" className={cn("flex flex-col", className)} {...frameProps(props)}> {content} </div> ) return ( <AppWindow data-slot="desktop-3" data-state={state} feel={feel} onClose={onRemindLater} aria-busy={busy || undefined} aria-labelledby={`${id}-title`} role="region" className={cn("h-[28rem] w-full max-w-xl", className)} {...frameProps(props)} > <AppWindowTitleBar title={win ? `${appName} update` : "Software Update"} icon={<RotateCw />} className="border-b-0" /> <AppWindowContent className="flex flex-col">{content}</AppWindowContent> </AppWindow> ) } export { Desktop3, type Desktop3Props, type Desktop3Release }Update the import paths to match your project setup.
Usage
import { Desktop3 } from "@/components/ballmac/blocks/desktop-3/desktop-3"
export default function Desktop3Demo() {
return <Desktop3 feel="macos" />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Checking
A spinner and "Checking for updates…" with the current version; marked busy.
state="loading"Downloading
A progress bar with megabytes done of the total; the release notes stay readable and Hide keeps it going in the background.
state="submitting" progress={0.4}Ready to relaunch
"Install and Relaunch" (macOS) or "Restart now" (Windows) calls onRelaunch; the other button installs on quit.
state="success"Up to date
Says the current version is the newest, with OK.
state="empty"Download failed
Says nothing was changed and offers Try Again (onRetry); errorState sets the copy.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 7 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (10% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
19 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, submitting, success, empty, error states, each with an example.
Passed: Automated tests
Covered by desktop-3.test.tsx.
For information: Footprint
4.4 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
An updater window. Leave state out and it runs itself: <Desktop3 currentVersion releases={[{ version, date, changes: [{ type: 'new' | 'improved' | 'fixed', text }] }]} onInstall={async (report) => { …; report(0.5) }} onRelaunch onSkip onRemindLater />. Or drive it from your updater with state (loading = checking, submitting + progress = downloading, success = ready, empty = up to date, error). macOS: Title Case buttons, default button last. Windows: sentence case, default button first. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Desktop 3: software update (@ballmac/desktop-3) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The update prompt of a Tauri app (tauri-plugin-updater) or an Electron app (electron-updater)
- A changelog-style "what's new" window after an update
Not for
- A public changelog page (use changelog-1)
- A small in-app banner that an update exists (use banner)
Registry JSON: https://ui.ballmac.com/r/desktop-3.json
Free to use in personal and commercial projects.
More desktop app blocks
Desktop 1: preferences window
The Settings window of a Tauri or Electron app: toolbar tabs on macOS, a side nav with setting cards on Windows. Launch at login, menu bar, appearance, a global shortcut recorder and update settings that apply as they change.
Desktop 2: menu bar and tray panel
The panel of a menu bar app on macOS or a tray flyout on Windows: a clipboard history with search, pins, a pause switch, keyboard selection and Preferences and Quit, with loading, empty and error states.
Desktop 4: licence activation
Licence activation for a paid desktop app: a trial meter, a key field that cleans up pasted keys, activation that shows why a key failed, the licensed view with deactivate, and a trial-ended mode.
Desktop 5: permissions setup
The first-run permissions window: why each one is needed, Allow for ones not asked yet, the exact System Settings path for ones turned off, a re-check on return, and Continue once required ones are on.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.