react-desktop-shell
v0.22.0
Published
A lightweight native-like desktop app shell for building React desktop applications.
Downloads
1,478
Maintainers
Readme
react-desktop-shell
A lightweight native-like desktop app shell for building React desktop applications.
Installation
npm install react-desktop-shellThe basic example uses lucide-react for icons. Install it separately with
npm install lucide-react, or replace those icons with your preferred icon
library. Lucide is not required by react-desktop-shell.
Fluent design alignment
The component library uses a Fluent-aligned token layer for desktop surfaces; it is an implementation guideline for this library, not a replacement for the official Fluent UI React styles.
The core geometry and rhythm are:
- Control radius:
--rds-radius-control(4px) - Overlay radius:
--rds-radius-overlay(8px) - Spacing:
--rds-space-1through--rds-space-4(4/8/12/16px) - Control heights:
--rds-control-height-standard(32px) and--rds-control-height-compact(28px) - Control icon slot:
--rds-control-icon-size(16px) - Body text:
--rds-font-size-body/--rds-line-height-body(14/20px) - Caption text:
--rds-font-size-caption/--rds-line-height-caption(12/16px)
Use the shared --rds-state-* variables for hover, pressed, selected, focus,
and disabled states. Keep component-specific variables as compatibility hooks,
but make their defaults resolve to the shared tokens so light, dark, system, and
forced-colors themes remain coherent.
Basic Usage
import { useState } from 'react'
import { FileText, Home, Settings, Wrench } from 'lucide-react'
import { AppPage, AppRail, AppShell, AppTitleBar } from 'react-desktop-shell'
import 'react-desktop-shell/style.css'
function App() {
const [active, setActive] = useState('home')
const [maximized, setMaximized] = useState(false)
return (
<AppShell
title="My App"
sidebar={{
displayMode: 'auto',
}}
titleBar={
<AppTitleBar
actions={<ToolbarActions />}
onMinimize={handleMinimize}
maximized={maximized}
onToggleMaximize={() => setMaximized((current) => !current)}
onClose={handleClose}
/>
}
rail={
<AppRail
value={active}
onChange={setActive}
items={[
{
key: 'home',
label: 'Home',
icon: <Home size={16} />,
},
{
type: 'group',
label: 'Workspace',
},
{
key: 'files',
label: 'Files',
icon: <FileText size={16} />,
},
{
key: 'tools',
label: 'Tools',
icon: <Wrench size={16} />,
disabled: true,
},
]}
footerItems={[
{
key: 'settings',
label: 'Settings',
icon: <Settings size={16} />,
},
]}
/>
}
>
<AppPage
key={active}
title={currentPage.title}
description={currentPage.description}
actions={currentPage.actions}
>
<CurrentPage />
</AppPage>
</AppShell>
)
}App Shell
AppShell composes the app identity, pane toggle, title bar, navigation rail,
and content area into a full-height desktop shell. The title bar spans the full
window and keeps the app identity and pane toggle stable while the navigation
rail switches between expanded, compact, and minimal modes.
To match native desktop applications, text selection and the text cursor are
disabled by default inside AppShell. Text inputs, textareas, and editable
content remain selectable. Add className="app-shell__selectable" or
data-app-selectable="true" to copyable content such as article text, logs, or
code output.
<AppShell
theme="system"
title="My App"
sidebar={{ displayMode: 'auto' }}
titleBar={<AppTitleBar />}
rail={<AppRail value={active} items={items} onChange={setActive} />}
>
<HomePage />
</AppShell>Theme
AppShell separates the light/dark color scheme from the accent color preset.
<AppShell theme="system">...</AppShell>
<AppShell theme="light">...</AppShell>
<AppShell theme="dark">...</AppShell>system is the default theme and follows the operating system color scheme through prefers-color-scheme. The theme is scoped to AppShell and does not modify html, body, or global application theme state.
Use themePreset to switch between the built-in blue, teal, green,
violet, orange, and rose accent palettes independently from the color
scheme. blue is the default and preserves the original AppShell palette.
<AppShell theme="system" themePreset="violet">...</AppShell>For a custom brand palette, use defineAppTheme and themeTokens. common
tokens apply to both schemes before the mode-specific tokens. Supplying only
accentColor also derives hover, pressed, active-background, focus-ring, and
info colors. Supply accentTextColor when the default white or near-black text
does not provide enough contrast for the chosen accent.
import { AppShell, defineAppTheme } from 'react-desktop-shell'
const brandTheme = defineAppTheme({
light: {
accentColor: '#6d5ce7',
contentBg: '#faf9ff',
},
dark: {
accentColor: '#b9adff',
contentBg: '#252330',
},
})
<AppShell theme="system" themeTokens={brandTheme}>...</AppShell>Theme selection is controlled state. AppShell does not persist either setting;
applications can store theme and themePreset using their preferred state or
settings layer.
AppSelectorBar
AppSelectorBar switches between a small number of mutually exclusive views or
data sets within the current page. Selection is shown with a short Fluent-style
indicator instead of a filled segmented-control surface. It owns selection,
keyboard interaction, and presentation only; it does not own business panels or
silently choose whether those panels remain mounted.
<AppSelectorBar
ariaLabel="Task status"
defaultValue="all"
items={[
{ key: 'all', label: 'All' },
{ key: 'open', label: 'Open' },
{ key: 'done', label: 'Completed' },
]}
/>Pass value and onChange for controlled selection. Without value, the
component uses defaultValue, or the first enabled item when no default is
provided. Removing or disabling the current item selects the first enabled item
and reports that fallback through onChange. Controlled mode is recommended
when selection drives application content because it keeps one explicit source
of truth.
const [view, setView] = useState('recent')
<AppSelectorBar
value={view}
onChange={setView}
items={[
{ key: 'recent', label: 'Recent', icon: <Clock /> },
{ key: 'favorites', label: 'Favorites', icon: <Heart /> },
{ key: 'history', label: 'History', icon: <History /> },
]}
/>Use AppSelectorPanels when a selector controls content panels and you want an
explicit mounting policy. The default unmount strategy renders only the active
panel:
const [view, setView] = useState('recent')
<AppSelectorBar
value={view}
onChange={setView}
items={[
{ key: 'recent', label: 'Recent', panelId: 'recent-panel' },
{ key: 'favorites', label: 'Favorites', panelId: 'favorites-panel' },
]}
/>
<AppSelectorPanels value={view} mountStrategy="unmount">
<AppSelectorPanel id="recent-panel" value="recent">
<RecentView />
</AppSelectorPanel>
<AppSelectorPanel id="favorites-panel" value="favorites">
<FavoritesView />
</AppSelectorPanel>
</AppSelectorPanels>With mountStrategy="unmount", switching releases inactive components and
their local state. Use mountStrategy="hidden" to keep every panel mounted:
<AppSelectorPanels value={view} mountStrategy="hidden">
<AppSelectorPanel value="recent"><RecentView /></AppSelectorPanel>
<AppSelectorPanel value="favorites"><FavoritesView /></AppSelectorPanel>
</AppSelectorPanels>Hidden panels use the standard hidden attribute, so they do not participate
in layout, focus navigation, or the active accessibility tree. Their React
component instances, subscriptions, and effects still exist, so use this mode
only when preserving local state is worth the retained resources.
Panels use motion="entrance" by default, adding a short fade and vertical
entrance when the active panel appears. Use motion="directional" to derive a
subtle left or right entrance from the panel order, or motion="none" to turn
panel motion off:
<AppSelectorPanels value={view} mountStrategy="hidden" motion="directional">
<AppSelectorPanel value="recent"><RecentView /></AppSelectorPanel>
<AppSelectorPanel value="favorites"><FavoritesView /></AppSelectorPanel>
</AppSelectorPanels>Directional motion falls back to a neutral fade when there is no previous selection or either value is missing from the current panel order. All panel motion is disabled when the user requests reduced motion.
The bar uses radiogroup semantics. ArrowLeft and ArrowRight move through
enabled items and wrap at the ends; Home and End move to the first and last
enabled items. Tab enters only the selected item (or the first enabled item),
and disabled items are skipped.
Choose the component according to the scope of the interaction:
AppRail: application-level primary navigation.AppSelectorBar: a few view choices within the current page.AppTabView: multiple documents or distinct content panels, especially when tab lifecycle matters.- Segmented Control: a compact, filled option switch.
AppSelectorBar itself is not intended for main navigation, closable or
reorderable tabs, large filter sets, overflow menus, or routing.
Document tabs
AppTabView provides WinUI-style document tabs without owning application
documents or host windows. It supports controlled and uncontrolled selection,
close and add requests, drag reordering, pinned and dirty states, keyboard
navigation, and explicit unmount or hidden panel lifecycles.
<AppTabView
items={documents.map((document) => ({
key: document.id,
label: document.name,
dirty: document.modified,
content: <Editor document={document} />,
}))}
value={activeDocument}
onValueChange={setActiveDocument}
onTabClose={requestCloseDocument}
onTabReorder={reorderDocuments}
/>Resizable panes
AppResizablePaneGroup creates a host-neutral two-pane workspace. Its separator
supports pointer dragging, arrow keys, Home/End, controlled sizing, and explicit
minimum and maximum dimensions.
Breadcrumb paths
AppBreadcrumbBar shows the current resource path and collapses earlier
ancestors into an accessible flyout when maxVisibleItems is exceeded. The
current location is non-interactive; applications own navigation state.
Fluent Cards
Button primitives
Commands and keyboard shortcuts
AppCommandProvider keeps command metadata and execution in one platform-neutral
model. Toolbars, menus, command palettes, and keyboard shortcuts can consume the
same command without importing Wails, Electron, Tauri, or another host runtime.
const commands: AppCommand[] = [{
id: 'file.save',
label: 'Save',
icon: <Save />,
shortcut: { ctrl: true, key: 's' },
execute: () => saveDocument(),
}]
<AppCommandProvider commands={commands}>
<Workspace />
</AppCommandProvider>Use useAppCommand to read one command and useAppCommands to execute commands
from a named surface. Disabled and hidden commands do not execute. Shortcuts are
ignored while users edit text by default; set allowInEditable only for a
command that intentionally overrides native editing behavior. Nested providers
inherit parent commands and may override them by id.
Menu bar
AppMenuBar provides a traditional File/Edit/View command surface. Entries may
carry their own presentation or reference an AppCommand; selection remains in
React and does not register a host-native menu. Top-level menus support access
keys and horizontal keyboard focus, while flyouts support standard menu keys.
Command palette
AppCommandPalette filters a supplied platform-neutral command collection,
supports arrow-key selection and Enter execution, and reports its open state to
the application. Its transient layout and dismissal behavior are provided by
AppSpotlightSurface; it does not discover or invoke host APIs itself.
AI, conversation, and quick-ask surfaces
The AI components are independent of any visual surface. Compose them inside a full chat page, a document context panel, or a transient shortcut surface according to the host workflow.
AI building blocks
AppAiComposer, AppAiMarkdown, AppAiRunIndicator, AppAiMessageActions,
AppPromptSuggestions, AppToolActivity, AppToolCallCard,
AppToolCallGroup, and AppChangeReviewCard are host-controlled AI building
blocks. They can be placed in any page or conversation and do not depend on
AppQuickAsk.
Migrating from 0.20.4 AI APIs
The AI lifecycle API is intentionally breaking and has no compatibility aliases. Replace the old request-oriented names and ambiguous statuses as follows:
| 0.20.4 | Current API |
| --- | --- |
| AppAiActivity | AppAiRunIndicator for the current run, or AppToolActivity / AppToolCallGroup for tool work |
| AppToolApprovalCard | AppToolCallCard |
| AppAiRequestStatus | AppAiRunStatus |
| Composer or Quick Ask status | runStatus |
| Request submitting / streaming | Run thinking / responding |
| Tool pending / denied | Tool awaiting-approval / rejected; after approval, move to running |
| Change review pending | Change review awaiting-review |
Run state now describes one host-owned AI run. Tool calls and change reviews keep their own lifecycle state in the conversation, so they are not folded into the composer's behavior.
Conversation building blocks
AppConversationMessage renders one user, AI, tool, or system message with
optional avatar, header, timestamp, footer, and actions. The component owns the
timestamp placement and <time> semantics; the host supplies the formatted
label and optional machine-readable timestampDateTime, so locale, time zone,
and relative-time policy stay in application control.
AppConversationThread renders a host-owned list of those messages.
AppConversationViewport wraps that content with follow-output,
pause-while-reading, jump-to-latest, and load-earlier behavior. None of these
components knows where the conversation is displayed.
<AppConversationViewport hasMore={hasMore} onLoadOlder={loadEarlier}>
<AppConversationThread messages={messages} />
</AppConversationViewport>Use AppAiMessageActions in the message actions slot for common controlled
copy, retry, edit, and feedback requests. It renders only the callbacks the host
provides and does not mutate messages or access the clipboard itself:
<AppConversationMessage
role="assistant"
timestamp="10:32"
timestampDateTime="2026-08-13T10:32:00+08:00"
metaVisibility="hover"
actions={
<AppAiMessageActions
feedback={feedback}
onCopy={() => copyMessage(message.id)}
onRetry={() => retryMessage(message.id)}
onFeedbackChange={setFeedback}
/>
}
>
{content}
</AppConversationMessage>Conversation metadata visibility defaults to always. Set
metaVisibility="hover" to reduce visual noise while keeping actions and
timestamps in sync; they also appear while the message is focused and remain
visible on touch or other devices without hover support. Assistant messages
place the time after actions, while user messages place it before them.
AppAiMessageActions still supports its own visibility prop when used
outside a conversation message.
Use AppConversationThread for the current session transcript. Place
AppToolCallCard entries in the thread when a tool needs explicit user
approval; the host remains responsible for storing messages and resuming or
rejecting the pending tool call.
Tool message content is capped at 760px by the conversation message layout on wide screens, while approval and change-review cards fill that content area. This keeps tool interactions readable without requiring per-card width styles.
Quick ask
AppSpotlightSurface is a top-centered transient dialog primitive with focus
management, focus restoration, nested overlay coordination, and configurable
Escape, outside-click, and window-blur dismissal. Use it for shortcut-first
surfaces that are not anchored to a visible trigger.
AppQuickAsk builds an AI prompt and response surface on that primitive. It
composes AppAiComposer for input, while the application owns request state,
streamed answer content, cancellation, and any native global-shortcut or window
integration. Hiding the surface therefore does not imply cancelling the request.
Use AppAiComposer directly inside a normal chat page when the page owns the
transcript and request state. Its default surface appearance provides a
self-contained two-level composer; use header, toolbarStart, and
toolbarEnd to add host-owned context, attachment, tool-mode, model, or voice
controls:
<AppAiComposer
appearance="surface"
header={attachedFiles}
onSubmit={sendPrompt}
onValueChange={setDraft}
runStatus={runStatus}
toolbarStart={attachmentActions}
toolbarEnd={modelAndVoiceActions}
value={draft}
/>runStatus is behavioral rather than visual inside the composer: it prevents
duplicate submission while the current response is being generated and
switches Send to Stop when onCancel is available. Tool execution, approval,
and change review belong to the response or conversation area; they do not
silently block or replace the composer's Send action. If a host needs to make
the whole composer unavailable, pass disabled with an explicit host-owned
reason elsewhere in the UI.
Use appearance="embedded" when the parent already owns the border, elevation,
and surrounding surface. AppQuickAsk uses this mode internally so its input
stays compact and does not render a second card inside the spotlight surface.
Use AppPromptSuggestions for controlled empty-state prompts. It only renders
the suggestions and reports the selected item; the host decides whether to put
the item's prompt into the draft or submit it immediately. The layout uses
responsive columns by default; pass columns={1}, columns={2}, columns={3},
or columns={4} when the host needs a fixed number of columns:
<AppPromptSuggestions
columns={2}
items={[
{
id: 'summarize',
label: 'Summarize this page',
description: 'Get the key points in a few bullets.',
prompt: 'Summarize this page',
},
]}
onSelect={(suggestion) => setDraft(suggestion.prompt)}
/>Use AppAiMarkdown for assistant-authored content. It renders CommonMark plus
GFM tables, task lists, links, and fenced code blocks with the same theme as the
conversation components. Raw HTML is skipped by default, external HTTP links
open in a new tab, and fenced code blocks include asynchronous Shiki syntax
highlighting plus an accessible copy action. Common languages are loaded on
demand and unsupported language labels fall back to plain text. Supported labels
include Bash, C/C++, C#, CSS, Dart, Dockerfile, Go, GraphQL, HTML, Java,
JavaScript/JSX, JSON/JSONC, Kotlin, Lua, Markdown, PHP, PowerShell, Python,
Ruby, Rust, SCSS, SQL, Swift, TOML, TypeScript/TSX, Vue, XML, YAML, and Zsh;
common aliases such as js, ts, py, rs, kt, ps1, and gql are also
accepted.
The message layout stays independent from Markdown rendering, so tool cards and
other React content can still be passed directly to AppConversationMessage:
<AppConversationMessage role="assistant">
<AppAiMarkdown content={answer} />
</AppConversationMessage>Pass components to replace selected Markdown elements, copyCode={false} to
hide code-copy actions, highlightCode={false} to use plain code blocks, or
onCopyCode when the host wants to provide its own clipboard integration.
Use AppAiRunIndicator to show the current run state without turning the
conversation into a progress dashboard. AppAiRunStatus describes one run,
not the conversation or an individual message. The indicator only renders that
compact feedback:
<AppAiRunIndicator
appearance="inline"
status={run.status}
detail={run.detail}
action={
run.status === 'awaiting-review' ? (
<AppButton onClick={openReview}>Review</AppButton>
) : undefined
}
/>AppAiComposer accepts the same value through runStatus, but only response
generation states (thinking, responding, and searching) control its Send
and Stop actions. Keep the lifecycle of each tool call in AppToolCallCard and
each proposed change in AppChangeReviewCard. In a workflow, keep one
host-owned phase and derive these component statuses from it instead of
synchronizing several independent state variables. completed means the
current run is finished, not that the conversation has been closed.
Conversation messages remain historical content and do not carry this
transient run state.
Use AppChangeReviewCard after a tool has prepared concrete file or code
changes. Keep the final write operation in the host and let the card handle
review details, Diff visibility, and the apply/reject decision:
<AppChangeReviewCard
files={files}
status={review.status}
onApply={() => applyChanges(review.id)}
onReject={() => rejectChanges(review.id)}
/><AppQuickAsk
answer={<AppConversationThread messages={messages} />}
onCancel={cancelRequest}
onOpenChange={setOpen}
onSubmit={sendPrompt}
onValueChange={setDraft}
open={open}
runStatus={runStatus}
value={draft}
/>A tool call is an ordinary controlled thread entry, so hiding and reopening the surface does not silently approve or reject an awaiting call:
<AppToolCallCard
title="Save meeting summary"
description="This writes one new file."
details="Documents/meeting-summary.md"
status={toolCall.status}
statusLabel={
toolCall.status === 'running' ? 'Saving meeting summary…' : undefined
}
onApprove={() => approveToolCall(toolCall.id)}
onReject={() => rejectToolCall(toolCall.id)}
onCancel={() => cancelToolCall(toolCall.id)}
/>awaiting-approval uses a warning badge and explicit actions. running uses
the shared small AppProgressRing with plain, action-specific text rather than
combining a spinner and badge. The ring is 14px with a 2px stroke and inherits
the library's 0.85s linear motion plus its static reduced-motion fallback.
completed, rejected, and canceled are quiet historical states; their
details are collapsed by default and can be controlled with expanded,
defaultExpanded, and onExpandedChange. rejected means permission was
denied before execution, while canceled means a started operation was stopped.
Use AppToolActivity for a lightweight event that does not need approval or a
full details card. Its status type intentionally excludes approval and
rejection; those decisions need an AppToolCallCard with explicit actions. Put
the action in the title so the spinner communicates specific work instead of
repeating a generic “using a tool” label:
<AppToolActivity
title="Saving meeting summary…"
description="Documents/meeting-summary.md"
status="running"
onCancel={() => cancelToolCall(toolCall.id)}
/>Use AppToolCallGroup for parallel activity. It derives aggregate progress
from items, renders one animated spinner in the group header, and uses static
status icons for child rows so several simultaneous tools do not create a field
of competing animations. Mixed failures remain visible in the aggregate label,
and approval decisions still belong in individual AppToolCallCard entries.
<AppToolCallGroup
items={[
{ id: 'read', title: 'Read README', status: 'completed' },
{ id: 'search', title: 'Search notes', status: 'running' },
{ id: 'draft', title: 'Prepare summary', status: 'running' },
]}
onCancel={(item) => cancelToolCall(item.id)}
/>Enter submits, Shift+Enter inserts a line break, and IME composition is
preserved. Register an AppCommand to open the component while the application
is focused; register the equivalent shortcut in Electron, Tauri, Wails, or the
host runtime when it must work system-wide.
The response viewport follows new output while the user remains within 48px of
the bottom. Scrolling up pauses following, returning to the bottom resumes it,
and submitting a new prompt or reopening the surface returns to the latest
message. Set followOutput={false} when the host needs full scroll control.
AppButton provides standard, primary, subtle, and danger desktop commands in compact or standard sizes. Standard controls are 32px high; compact controls are 28px high. It supports leading or trailing icons, stable loading states, native button attributes, and ref forwarding. Use AppIconButton for icon-only commands and always supply ariaLabel or aria-label; compose tooltips with AppTooltip.
<AppButton appearance="primary" icon={<Save />} loading={saving}>Save</AppButton>
<AppIconButton ariaLabel="More actions" appearance="subtle" icon={<MoreHorizontal />} />Compact control groups
AppCompactGroup visually joins adjacent controls by collapsing shared borders
and applying outer corner radii. It is deliberately stateless: buttons retain
independent actions, and inputs retain their own values, events, refs, and form
behavior. Use AppToggleButtonGroup for persistent button selection and
AppSegmentedControl for mutually exclusive modes instead. Direct children
must accept and forward className.
AppControlAddon provides non-interactive leading, trailing, or intermediate
content such as units and short labels. Match its size to compact child
controls when necessary; AppCompactGroup does not infer or propagate a size
to its children, so pass size="compact" to each child explicitly.
<AppCompactGroup>
<AppControlAddon>Last</AppControlAddon>
<AppNumberBox min={1} value={count} onValueChange={setCount} />
<AppControlAddon>times</AppControlAddon>
</AppCompactGroup>
<AppCompactGroup aria-label="History actions">
<AppButton>Back</AppButton>
<AppButton>Forward</AppButton>
<AppButton>Refresh</AppButton>
</AppCompactGroup>Fields and empty states
AppField supplies label, description or error messaging, required state, and vertical or settings-friendly horizontal layout without controlling its child input. AppEmptyState presents empty content at inline, content, or fill layouts, with optional visual, title, description, and actions. The legacy icon, action, and appearance props remain supported.
<AppField id="student-name" label="Student name" description="Used in feedback reports" required error={error}>
<AppTextBox />
</AppField>
<AppEmptyState title="No students yet" description="Add a student to begin." action={<AppButton>Add student</AppButton>} />
<AppEmptyState
layout="fill"
visual="default"
title="No matching students"
description="Try changing or clearing the current filters."
/>
<AppEmptyState layout="inline" size="small" visual="none" description="No matching results" />RDS inputs consume field associations automatically. For a native or third-party control, provide matching identifiers manually:
<AppField htmlFor="external-name" messageId="external-name-help" label="Name" description="Shown in reports">
<input id="external-name" aria-describedby="external-name-help" />
</AppField>Form state, validation, and dynamic fields
Use useAppForm when a form needs shared nested values, validation, dirty and
touched metadata, submission state, or dynamic lists. AppForm supplies the
native form boundary and layout; AppFormField connects an individual control;
AppFormSection, AppFormList, and AppFormErrorSummary provide grouping,
repeatable rows, and focusable error feedback.
type ProfileForm = {
email: string
contacts: Array<{ value: string }>
}
const form = useAppForm<ProfileForm>({
defaultValues: {
email: '',
contacts: [{ value: '' }],
},
onSubmit: async ({ values, dirtyValues, signal }) => {
await saveProfile({ values, dirtyValues, signal })
},
onSubmitError: (error, { form }) => {
if (isEmailConflict(error)) {
form.setErrors({ email: 'This email is already registered' })
}
},
})
<AppForm form={form} layout="grid" columns={{ base: 1, md: 2 }}>
<AppFormErrorSummary form={form} />
<AppFormField<ProfileForm, string>
label="Email"
name="email"
required
validators={{
onChange: ({ value }) => isEmail(value) ? undefined : 'Enter a valid email',
onBlur: async ({ value, signal }) =>
await checkEmailAvailability(value, { signal }),
}}
>
{({ value, setValue, onBlur }) => (
<AppTextBox
onBlur={onBlur}
onChange={(event) => setValue(event.currentTarget.value)}
value={value}
/>
)}
</AppFormField>
<AppFormList<{ value: string }> name="contacts">
{({ append, fields, remove }) => (
<AppFormSection title="Contacts">
{fields.map((field) => (
<div key={field.key}>
<AppFormField<ProfileForm, string>
label={`Contact ${field.index + 1}`}
name={['contacts', field.name, 'value']}
>
{({ value, setValue }) => (
<AppTextBox
onChange={(event) => setValue(event.currentTarget.value)}
value={value}
/>
)}
</AppFormField>
<AppButton type="button" onClick={() => remove(field.index)}>
Remove
</AppButton>
</div>
))}
<AppButton type="button" onClick={() => append({ value: '' })}>
Add contact
</AppButton>
</AppFormSection>
)}
</AppFormList>
<AppButton appearance="primary" type="submit">Save</AppButton>
</AppForm>Names accept dot paths such as contacts.0.value or segment arrays such as
['contacts', 0, 'value']. An array is always one field path; to target several
fields, pass separate arguments to form.validate('email', 'name') or
form.clearErrors('email', 'name'). Calling either method without names targets
all registered fields or all errors respectively.
Field and form validators may run on change, blur, or submit. Async validators
receive an AbortSignal; changing, resetting, removing, or reordering affected
values invalidates stale validation work. AppFormList keeps stable render keys
and moves registered fields, errors, touched, dirty, and validating metadata
with each item. Field values are preserved when a conditional field unmounts by
default; set preserve={false} when unmounting should remove its value and
metadata. Use useAppFormSelector for reactive host-owned status UI,
form.state or getSnapshot() for imperative inspection, and
getDirtyValues() for partial update payloads.
Progress and status
Use AppProgressRing for indeterminate work, AppProgressBar for determinate or inline indeterminate progress, and AppStatusBadge for one of the fixed semantic statuses. Built-in progress and status labels follow the AppShell locale. All progress controls expose native ARIA roles and static reduced-motion fallbacks.
<AppProgressBar value={68} label="Importing students" showValue />
<AppStatusBadge status="success">Complete</AppStatusBadge>Background task center
AppTaskCenter renders application-owned task snapshots and emits cancel,
retry, and dismiss requests. AppTaskIndicator provides a compact active-task
count for a status bar or toolbar. Neither component subscribes to a backend,
so Wails events, Electron IPC, Tauri events, or another transport stay outside
the component library.
Localization
AppShell is the single public localization entry point. The component library
includes fixed Simplified Chinese and US English messages and does not require
an external i18n dependency.
import { AppShell, type AppLocale } from 'react-desktop-shell'
const [locale, setLocale] = useState<AppLocale>('system')
<AppShell locale={locale}>
<App />
</AppShell>Use locale="zh-CN" or locale="en-US" to select a language explicitly.
The default, locale="system", maps every Chinese system language, including
Traditional Chinese language tags, to the built-in Simplified Chinese locale;
all other system languages use US English. Changing locale at runtime updates
regular content and shell-managed overlays, dialogs, menus, and toasts.
Built-in labels, placeholders, date formatting, weekday order, and time display
are intentionally controlled only by AppShell. Component-level localization
props are not supported. Applications should continue to localize their own
content, such as field labels, table column names, empty states, and custom
actions.
When migrating from an earlier release, remove Date/Time Picker props such as
locale, localeText, placeholder, startPlaceholder, endPlaceholder,
formatValue, firstDayOfWeek, and hourCycle. Also remove the former
AppShell contextMenuLocale, messageBoxLocale, and toastLocale props and
DataTable control or pagination locale overrides.
Text inputs
AppTextBox wraps native input behavior with optional icons, clear and loading affordances. Their built-in accessible labels follow the AppShell locale. AppTextArea supports character counts, full-width layout, and dependency-free automatic height within row limits, including padding and borders. Its native style and other textarea attributes apply to the inner control, while className and fullWidth configure the component root. Both forward refs and compose with AppField.
AppSearchBox keeps onValueChange immediate for controlled input state while
deferring debounced onSearch calls until IME composition finishes. Enter and
Escape do not submit or clear while the user is choosing a composed character.
Use onSearch rather than onValueChange for filtering that should wait until
composition completes.
<AppTextBox value={name} onChange={(event) => setName(event.target.value)} clearable />
<AppTextArea autoResize minRows={2} maxRows={8} showCount maxLength={500} />
<AppTextArea fullWidth placeholder="Notes" />
<AppSearchBox debounceMs={250} onSearch={setFilter} onValueChange={setQuery} value={query} />Selection controls
AppCheckBox uses a native checkbox and supports controlled, uncontrolled, and indeterminate states. While indeterminate remains true, the native property and aria-checked="mixed" are restored after every interaction; onCheckedChange reports the browser's resulting boolean checked value, and the parent decides when to clear indeterminate. AppCheckBoxGroup manages a string array across labelled horizontal or vertical options. AppRadioGroup provides labelled horizontal or vertical single-choice fields, while AppSegmentedControl presents a few short choices in a compact filled surface and preserves string or numeric values in its callback. AppToggleSwitch exposes switch semantics, immediate state changes, label placement, and compact sizing.
<AppCheckBox checked={selected} onCheckedChange={setSelected} label="Include suggestions" />
<AppRadioGroup defaultValue="comfortable" label="Density" options={densityOptions} />
<AppSegmentedControl ariaLabel="Layout" defaultValue="list" options={layoutOptions} />
<AppCheckBoxGroup name="topics" options={topicOptions} value={topics} onValueChange={setTopics} />
<AppToggleSwitch defaultChecked label="Automatic updates" />Date picker
AppDatePicker selects a calendar date represented by AppDateValue, which
contains only year, month, and day and therefore has no time or time zone.
The component supports controlled or uncontrolled values and open state,
minimum and maximum dates, unavailable-date callbacks, AppField, clear
actions, and dialog-local overlays.
minValue and maxValue also constrain calendar month navigation.
const [date, setDate] = useState<AppDateValue | null>(null)
<AppDatePicker
allowClear
name="courseDate"
onValueChange={setDate}
value={date}
/>When name is supplied, a hidden input submits YYYY-MM-DD. The visible
formatted value remains locale-aware. readOnly allows opening the calendar
for inspection but prevents selection and clearing; disabled prevents all
interaction.
Date range picker
AppDateRangePicker uses a complete AppDateRangeValue for committed state.
By default, selections inside the calendar remain pending until Apply is
pressed. Set commitMode="auto" to commit and close as soon as a complete,
valid range is selected. In either mode, dismissing an incomplete selection
with Escape or an outside pointer does not call onValueChange. Range lengths
include both endpoints. Its default minimum width adapts to its container and
can be customized with --rds-date-range-picker-min-width.
const [range, setRange] =
useState<AppDateRangeValue | null>(null)
<AppDateRangePicker
endName="endDate"
commitMode="auto"
onValueChange={setRange}
startName="startDate"
value={range}
visibleMonths="responsive"
/>Use minDuration and maxDuration to validate inclusive natural-day lengths.
The start and end dates must be selectable; unavailable dates may still occur
inside the range. minValue and maxValue constrain both selectable dates
and calendar month navigation. Separate startName and endName hidden
inputs submit ISO calendar dates.
Date and AppDateValue
AppDateValue represents a calendar date without a time or time zone. Use
parseAppDateISO and formatAppDateISO for strict YYYY-MM-DD interchange.
Do not pass an ISO date-only string directly to new Date(value), because that
syntax is parsed as UTC and can display a different local day. When a native
Date is required, construct it from local fields:
new Date(value.year, value.month - 1, value.day)Time picker
AppTimePicker uses AppTimeValue, a local wall-clock time containing only
hour and minute. It has no date or time zone. Changes in the panel remain
pending until Apply is pressed; Cancel, Escape, and outside pointer dismissal
discard pending changes.
const [time, setTime] =
useState<AppTimeValue | null>({
hour: 18,
minute: 30,
})
<AppTimePicker
allowClear
minuteStep={5}
onValueChange={setTime}
value={time}
/>Use minValue and maxValue to constrain selectable combinations and
the resolved AppShell locale controls the display: Simplified Chinese uses a
24-hour clock and US English uses a 12-hour clock. Hidden form inputs always
submit strict HH:mm values regardless of display format.
Time range picker
AppTimeRangePicker represents a range within one calendar day. The end must
be later than the start; equal, reversed, overnight, and cross-midnight ranges
are invalid and are never automatically reordered.
const [range, setRange] =
useState<AppTimeRangeValue | null>({
start: { hour: 9, minute: 0 },
end: { hour: 10, minute: 30 },
})
<AppTimeRangePicker
endName="endTime"
minuteStep={5}
onValueChange={setRange}
startName="startTime"
value={range}
/>The start and end editors share one compact time panel. Apply commits the
pending range; Cancel, Escape, and outside pointer dismissal do not submit.
minDuration and maxDuration are measured in minutes. Separate hidden inputs
submit the start and end as HH:mm.
Number and select controls
AppNumberBox separates temporary editing text from its committed value. Blur and Enter commit valid input, Escape restores the committed value, and buttons or Arrow keys apply normalized steps. Pointer step buttons keep the input focused and apply pending valid text as a single final update. In controlled mode, rejected parent updates restore the current prop value. AppSelect visually wraps a native single-value select. Selected values are strings, matching native form behavior, while null explicitly represents no selection and undefined leaves the component uncontrolled. placeholder labels the empty state, defaultValue only initializes uncontrolled state, and clearable lets non-required selections return to null. Convert domain numbers at the application boundary.
<AppNumberBox value={duration} min={1} max={180} step={5} onValueChange={setDuration} />
<AppSelect options={courses} value={course} onValueChange={setCourse} />
<AppSelect clearable options={courses} placeholder="Choose a course" />Combo box
AppComboBox combines editable filtering with strict option selection. Typing
only filters the option list; choosing an option commits its value, while the
input displays its text label. Unsupported text is restored on blur or
Escape. It supports controlled and uncontrolled values, keyboard navigation,
clearing, and AppField associations.
<AppComboBox
clearable
options={courseTypes}
value={courseType}
onValueChange={setCourseType}
/>Use AppAutoComplete instead when arbitrary text is a valid committed value
and options are suggestions rather than the complete set of allowed values.
Non-text option labels fall back to the option value inside the input.
List view
AppListView and AppListViewItem form a desktop information list with mutually exclusive static, selection, and invoke modes. Static mode uses list/listitem semantics. Selection mode keeps each row as a listitem and uses a native radio or checkbox in the main label, so trailing actions remain separate controls; a disabled item disables its selection control and marks its trailing region inert. Invoke mode keeps each role="button" main action and its trailing controls as siblings inside a listitem, with Arrow/Home/End navigation and Enter/Space activation.
<AppListView ariaLabel="Students" selectionMode="multiple" value={selected} onValueChange={setSelected}>
<AppListViewItem value="ada" title="Ada" description="Grade 5 · Python" />
</AppListView>Tree view
AppTreeView presents platform-neutral hierarchical resources with single or
multiple selection, controlled expansion, keyboard navigation, lazy child-load
requests, invocation, and drag/drop requests. Applications retain ownership of
the tree data and perform file-system or backend operations themselves.
<AppTreeView
items={projectNodes}
expandedKeys={expanded}
onExpandedKeysChange={setExpanded}
onLoadChildren={loadDirectory}
onItemDrop={moveResource}
/>Status bar
AppStatusBar and AppStatusBarItem create a compact persistent footer for
connection state, selection counts, cursor position, encoding, zoom, and other
workspace context. Items are static by default and become buttons only when
interactive is set.
Property grid
AppPropertyGrid arranges application-owned editors in dense, collapsible
property groups. It supports modified indicators, reset requests, read-only
metadata, descriptions, and keyboard adjustment of the property-name column.
Expander
AppExpander reveals low-frequency settings or details with controlled or uncontrolled state. Its header uses a linked aria-expanded button, while optional header actions remain independent. Collapsed content becomes inert during closing and is hidden after the transition, preventing controls from leaking into the Tab order; reduced-motion users switch immediately.
<AppExpander title="Advanced settings" description="Usually no changes are needed.">
<AdvancedSettings />
</AppExpander>AppExpanderGroup joins adjacent expanders into one continuous surface. It
keeps each item independent by default. Set expansionMode="single" for
accordion behavior or "multiple" for group-owned multi-expansion state.
Coordinated items use value; the group supports controlled and uncontrolled
values and Arrow Up, Arrow Down, Home, and End navigation.
<AppExpanderGroup
collapsible={false}
defaultValue="general"
expansionMode="single"
>
<AppExpander title="General" value="general">
<GeneralSettings />
</AppExpander>
<AppExpander title="Advanced" value="advanced">
<AdvancedSettings />
</AppExpander>
</AppExpanderGroup>Popover
AppPopover renders lightweight, non-modal supporting content in the overlay portal. It is suitable for auxiliary information, compact form editing, and other interactions that should leave the rest of the page available. It does not lock focus, make the page inert, or prevent background interaction. It supports controlled or uncontrolled state, anchored placement with collision handling, outside/Escape dismissal, Escape focus restoration, optional initial focus, and trigger-width matching. Pass ariaLabel when the content should be exposed as a named region. The trigger must be a ref-capable DOM element or forwardRef component, not a Fragment.
<AppPopover trigger={<AppButton>View details</AppButton>} placement="bottom-start">
<AppTextBox placeholder="Optional note" />
</AppPopover>Do not assemble confirmation behavior manually inside AppPopover.
AppConfirmPopover reuses the same anchored overlay infrastructure for a
small, local decision. It focuses Cancel first, treats Escape, outside
dismissal, and trigger dismissal as cancellation, restores trigger focus, and
keeps the surface open with a loading state while an asynchronous confirmation
is pending. A rejected confirmation remains open and is reported through
onConfirmError.
<AppConfirmPopover
title="Delete this item?"
description="This removes it from the current list."
confirmText="Delete"
confirmAppearance="danger"
onConfirm={deleteItem}
onConfirmError={reportError}
trigger={<AppButton appearance="danger">Delete</AppButton>}
/>Use AppConfirmPopover only for a single nearby action. Use
useAppMessageBox().confirm() or AppDialog for global, highly destructive, or
multi-step decisions. Use AppMenuFlyout for menus and commands,
AppTeachingTip for guidance, and AppTooltip for hints.
AppCard is a low-contrast Fluent content surface for desktop tools, settings,
status summaries, recent projects, and utility entry points. It is not a fixed
web-dashboard panel: cards have no strong shadow or title divider by default,
and ordinary cards are static div elements without hover or button semantics.
Compose a card from four focused components:
AppCardprovides one surface, border, radius, spacing, and optional interaction states.AppCardHeaderarranges leading media, title, description, and a trailing action. It does not provide a surface or divider.AppCardFooterarranges supporting information and actions. It is transparent and undivided by default.AppCardGroupmerges adjacentAppCardborders and corner radii without managing selection or other business state.
<AppCard>
<AppCardHeader
icon={<DatabaseBackup />}
title="Data backup"
description="Protect local application data"
action={<button aria-label="More options">...</button>}
/>
<div>Last backup: today at 10:30</div>
<AppCardFooter
start={<span>24.6 MB</span>}
end={<button>Back up now</button>}
/>
</AppCard>Appearance and spacing
appearance="filled" is the default and uses the raised content-surface token
with a subtle border. outlined keeps the background transparent and relies on
its border. subtle starts with a transparent background and border, revealing
a light surface only when interactive. Padding can be none, compact, or
regular; orientation can be vertical or horizontal.
Horizontal orientation lays out the card's direct children in a row. Keep the standard Header/content/Footer composition vertical; horizontal mode is intended for compact custom tool rows where the icon, text, and action are direct children.
Interaction and selection
Providing onClick makes a card interactive by default. Interactive cards use
role="button", enter the Tab order, and activate with Enter or Space. Set
interactive={false} to retain a mouse handler without automatic button
semantics or interaction styling. Explicit interactive={true} opts into visual,
focus, and button semantics even without an onClick; applications should only
do this when they will supply a meaningful activation behavior.
disabled blocks mouse and keyboard activation and removes an interactive card
from the Tab order. selected is controlled visual state only. Interactive
selected cards expose aria-pressed; static selected cards do not acquire a
button, radio, or checkbox role. Buttons, links, inputs, and other interactive
descendants work normally without activating the parent card.
Footer and groups
When neither start nor end is supplied, AppCardFooter renders children as
complete custom content. Once either side is supplied, it uses a three-region
layout: start, optional middle children, and end. The start and middle can
shrink; the end action region does not. divided adds a single semantic top
border and should be reserved for content that needs an explicit boundary.
<AppCardGroup>
<AppCard orientation="horizontal">Theme <strong>System</strong></AppCard>
<AppCard orientation="horizontal">Accent <strong>Blue</strong></AppCard>
<AppCard orientation="horizontal">Animations <strong>On</strong></AppCard>
</AppCardGroup>AppCardGroup defaults to a vertical continuous group with dividers. Horizontal
groups merge left and right corners instead. Non-Card children are rendered but
only direct AppCard children receive the complete border-merging treatment.
Cards primarily express Fluent surface hierarchy. Avoid putting every page
element in a card, wrapping every ordinary list row in a separate floating card,
or treating a card as a fixed title-bar container. Use AppCardGroup for
continuous setting or property rows. Existing Settings components share the
same card surface and radius tokens but remain purpose-built controls with their
own public API and structure.
AppScrollArea
AppScrollArea keeps a native scrolling viewport for the browser's mouse-wheel,
trackpad, touch, keyboard, inertia, and programmatic scrolling behavior, then
draws a WinUI-style scrollbar overlay above it. The compact indicator is 2px
wide and morphs to a 6px traditional thumb with arrow buttons when hovered.
Thumb position, dragging, track paging, and overflow changes are synchronized
with the native viewport; application scroll state remains native.
The component does not assign its own width or height. Its parent or the supplied style must create a constrained scrolling region:
<AppScrollArea style={{ height: 320 }}>
<LongSettingsList />
</AppScrollArea>When the scroll area is a flex child that should consume the remaining height,
use fill and constrain the parent:
<div style={{ display: 'flex', flexDirection: 'column', height: 320, minHeight: 0 }}>
<Toolbar />
<AppScrollArea fill>
<LongSettingsList />
</AppScrollArea>
</div>orientation controls viewport overflow and which overlay axes are available:
verticalis the default: vertical overflow is automatic and horizontal overflow is hidden.horizontalenables horizontal overflow and hides vertical overflow.bothenables native overflow in both directions.
scrollbar controls scrollbar visibility policy:
autoshows each custom axis only when its content overflows and is the default.alwayskeeps the enabled custom axis visible, including its disabled state when that direction does not currently overflow.hiddenhides only the visual scrollbar; wheel, trackpad, touch, keyboard, and programmatic scrolling remain available.
gutter="stable" reserves the 12px traditional scrollbar surface instead of
overlaying it on content. It can be useful for long lists, table-adjacent
containers, settings pages, and fixed-layout dialogs, but is not the default
because small panels should not always lose content width.
<AppScrollArea
aria-label="Release notes"
gutter="stable"
orientation="vertical"
role="region"
style={{ maxHeight: 240 }}
tabIndex={0}
>
<ReleaseNotes />
</AppScrollArea>Scroll areas do not receive tabIndex or role="region" automatically. Add
tabIndex={0} only when users need to focus the scrolling region directly, and
provide an accessible label with it. The forwarded ref and HTML attributes
target the native scrolling viewport; className and style target the outer
layout container. Use viewportClassName and viewportStyle when styling the
native viewport itself. Custom scrollbar controls are pointer-only and hidden
from the accessibility tree so the viewport remains the single keyboard and
assistive technology surface. Forced-colors mode falls back to the platform
scrollbar.
The WinUI scrollbar overlay is scoped to AppScrollArea; existing internal
library containers retain their native fallback until they are migrated. It does
not globally affect editors, third-party popups, form controls, or every element
on the page. Do not wrap an existing scroll container in another
AppScrollArea, use it as a substitute for virtual lists, or expect overflow to
occur without a constrained size.
Dependencies and theming
The core component library does not depend on a third-party UI component
library. Built-in controls automatically follow the light, dark, or
system theme configured on AppShell.
Table features use TanStack Table only when the optional data entry point is
imported. Icons are supplied by the application; the Example uses
lucide-react.
Migrating to 0.9
Version 0.9 removes the former react-desktop-shell/antd entry point and
createAntdTheme. Applications that also use Ant Design must manage that
library and its theme configuration independently.
Optional Data View
Data-view components are available from the optional react-desktop-shell/data
subpath. Install TanStack Table alongside the shell when you use this entry:
npm install react-desktop-shell @tanstack/react-table@tanstack/react-table is required when using AppDataTable. Vertical row
virtualization is optional and requires one additional peer dependency:
npm install @tanstack/react-virtualAppDataView composes the toolbar or selection bar, table, and optional footer
into one bordered surface. AppSelectionBar lays out a selection count and
consumer-provided batch actions without owning selection state. AppDataTable
uses TanStack Table's ColumnDef<TData> directly.
The data table supports client-side pagination, client or manual sorting, optional built-in global
search and column filters opened from each column menu, manual/server-side filtering, column
visibility, controlled row selection, rectangular cell selection and TSV copy,
loading and empty states, comfortable and compact density, cell keyboard navigation, row activation, horizontal
scrolling, and opt-in column sizing. Compact density is the default. Column sizing
supports controlled or uncontrolled state, mouse and touch resizing, minimum
and maximum widths, per-column resize control, onEnd and onChange modes,
and double-click reset. Sticky table headers and controlled or uncontrolled
left/right column pinning compose with resizing and visibility. Sticky columns
show a small pin indicator in their headers and a subtle one-sided boundary only
after they reach their sticky position. The automatically pinned selection
column keeps the regular grid divider without adding another shadow. The table
does not provide column order dragging.
import { useState } from 'react'
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import { AppToolbar } from 'react-desktop-shell'
import {
AppDataTable,
AppDataView,
AppSelectionBar,
type AppDataTableCellRange,
} from 'react-desktop-shell/data'
type Student = {
id: string
name: string
category: string
status: string
}
const columns: ColumnDef<Student>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'category', header: 'Category' },
{ accessorKey: 'status', header: 'Status' },
]
function StudentsView({ students }: { students: Student[] }) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const selectedCount = Object.values(rowSelection).filter(Boolean).length
return (
<AppDataView
toolbar={
<AppToolbar
appearance="flat"
status={`${students.length} students`}
/>
}
selectionBar={
selectedCount > 0 ? (
<AppSelectionBar
count={selectedCount}
onClear={() => setRowSelection({})}
/>
) : null
}
footer={`Showing ${students.length} students`}
>
<AppDataTable
columns={columns}
data={students}
getRowId={(student) => student.id}
rowSelection={{
value: rowSelection,
onChange: setRowSelection,
selectAllMode: 'filtered',
}}
/>
</AppDataView>
)
}Provide a stable getRowId when enabling row selection, sorting, or filtering.
The table never adds fields to or mutates input data.
rowSelection.mode defaults to 'multiple'. Both modes reuse one leading
selection/status column rather than adding a separate Corner column. Multiple
mode uses a 44px column with centered checkboxes. Single mode narrows the same
column to 16px and replaces the checkboxes with a selected-row accent indicator;
clicking a selectable row replaces the current selection. The status column
header remains intentionally blank.
In multiple mode, rowSelection.selectAllMode defaults to 'filtered'. The header
checkbox selects or clears only selectable rows in the current filtered result.
Use 'all' to make it operate on all data rows instead. Changing filters does
not automatically clear already selected rows outside the filtered result.
When pagination is enabled, use 'page' to select or clear only the current
page; 'filtered' continues to include matching rows on every page.
AppDataTable currently supports flat leaf-column definitions. Multi-level
grouped headers are not currently supported.
Each ordinary column header separates its sort target from a column menu containing only the core sorting and filtering actions. The column panel uses the shared popover positioning and context-menu action styling, while only the filter option list scrolls. Column menus are part of the default desktop interaction and do not require additional props. The internal selection/status column has no menu.
The body uses a roving cell focus model. Press Tab to enter the current data
cell, arrow keys to move between visible cells, Home or End to move within
the row, and Ctrl+Home or Ctrl+End to move to the first or last data cell.
Interactive descendants such as links, buttons, inputs, and checkboxes retain
their native keyboard behavior. Pressing Enter in a data cell invokes
single-row selection and invokes onRowClick when that callback is provided.
Pass cellSelection to enable Excel-style cell interaction independently of
rowSelection. Click selects one cell, pointer dragging selects a logical
rectangle, and Shift + click or Shift + arrow extends from the current
anchor. A plain arrow key collapses the range to the next active cell.
The native copy event (including Ctrl+C, Cmd+C, and browser/WebView Edit →
Copy) copies the current rectangle as tab-separated rows in the current sorted
and filtered order, including offscreen virtual rows. Hidden and internal control
columns are excluded. Cell ranges are scoped to the current page and clear when
an endpoint is no longer present after pagination, filtering, data, or column
visibility changes. Set copy: false to leave native copy handling untouched.
const [cellRange, setCellRange] = useState<AppDataTableCellRange | null>(null)
<AppDataTable
columns={columns}
data={students}
getRowId={(student) => student.id}
cellSelection={{
value: cellRange,
onChange: setCellRange,
copy: true,
}}
getCellCopyValue={(cell) => String(cell.getValue() ?? '')}
/>For custom context-menu or toolbar actions, pass a clipboard writer and use
the table handle. The writer has the same shape as AppCopyableText's copy
prop, so the host can provide the implementation for browsers, WebViews,
Electron, or other runtimes:
const tableRef = useRef<AppDataTableHandle | null>(null)
<AppDataTable
ref={tableRef}
cellSelection={{
value: cellRange,
onChange: setCellRange,
copy: (text) => window.nativeBridge.clipboard.writeText(text),
}}
columns={columns}
data={students}
/>
<button onClick={() => tableRef.current?.copySelectedCells()}>
Copy selected cells
</button>copySelectedCells() returns an explicit result with copied, skipped, or
failed status. copy: false disables both native and imperative cell copy.
When the host does not expose writable clipboardData, native copy events use
the configured writer as a fallback. If no writer is provided, imperative
copies use the Web Clipboard API and then the browser's native copy command
when available. onCopy and onCopyError can be used for the same feedback
lifecycle as AppCopyableText.
The range stores only stable { rowId, columnId } anchor and focus endpoints;
its size is constant regardless of how many cells it covers. Copy values come
from the table data model, not rendered React nodes or DOM text. Interactive
descendants such as buttons, links, form controls, and editable content keep
their native pointer and keyboard behavior.
The default compact layout uses a 36px header and 38px data rows. Pass
density="comfortable" for a 44px header and 48px rows. Table backgrounds
continue to inherit shell surface variables; header, hover, selected, pressed,
focus, border, and accent visuals use the corresponding semantic
--app-data-* and --rds-state-* tokens.
Client-side pagination
Enable the built-in client-side pagination row model with pagination. Search
and column filters run against the full data set, sorting runs next, and only
then are the resulting rows split into pages. Pass the complete data array;
the application should not call slice() before rendering the table.
<AppDataTable
data={students}
columns={columns}
pagination={{
defaultValue: {
pageIndex: 0,
pageSize: 10,
},
pageSizeOptions: [10, 20, 50],
}}
/>pagination={true} uses a 10-row initial page and the default page-size
choices of 10, 20, and 50. The object form supports uncontrolled state through
defaultValue, or controlled state through value and onChange. Use
rowSelection.selectAllMode: 'page' when the header checkbox should affect only
the visible page. This version supports client-side pagination only.
Vertical row virtualization
For longer continuously scrolling data sets, enable fixed-height vertical row virtualization inside a constrained scroll area:
<AppDataView height="fill">
<AppDataTable
data={students}
columns={columns}
virtualization={{
overscan: 5,
}}
/>
</AppDataView>Virtualization requires @tanstack/react-virtual and either a fill-height data
view or an AppDataTable maxHeight. It reduces rendered row DOM only: search,
column filtering, and sorting still run against the complete data set. The
default fixed row height follows density (38px for the default compact
density and 48px for comfortable). A custom rowHeight must match the actual
CSS row height.
Only fixed-height vertical row virtualization is supported. Dynamic row heights and horizontal column virtualization are not supported. For roughly a few hundred rows, consider pagination first; use virtualization when continuous scrolling is important. Pagination and virtualization can be enabled together, but only the current page is virtualized, so the benefit is usually limited.
Filtering and column visibility
Pass controls for the optional built-in global search field and column filter
definitions. The top control row contains the global search; filter definitions
are opened from the matching column's ellipsis menu. Selecting options edits a
local draft and Apply commits it to TanStack Table's columnFilters; Cancel,
Escape, or an outside click discards the draft. Single filters use one string
value, while multiple filters use a string array. Without controls, no
additional control-bar DOM is rendered.
const categoryOptions = Array.from(
new Set(students.map((student) => student.category)),
).map((value) => ({ value, label: value }))
<AppDataTable
data={students}
columns={columns}
controls={{
search: true,
filters: [
{
columnId: 'category',
label: 'Category',
options: cate