npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-desktop-shell

v0.22.0

Published

A lightweight native-like desktop app shell for building React desktop applications.

Downloads

1,478

Readme

react-desktop-shell

A lightweight native-like desktop app shell for building React desktop applications.

Installation

npm install react-desktop-shell

The 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-1 through --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:

  • AppCard provides one surface, border, radius, spacing, and optional interaction states.
  • AppCardHeader arranges leading media, title, description, and a trailing action. It does not provide a surface or divider.
  • AppCardFooter arranges supporting information and actions. It is transparent and undivided by default.
  • AppCardGroup merges adjacent AppCard borders 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:

  • vertical is the default: vertical overflow is automatic and horizontal overflow is hidden.
  • horizontal enables horizontal overflow and hides vertical overflow.
  • both enables native overflow in both directions.

scrollbar controls scrollbar visibility policy:

  • auto shows each custom axis only when its content overflows and is the default.
  • always keeps the enabled custom axis visible, including its disabled state when that direction does not currently overflow.
  • hidden hides 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-virtual

AppDataView 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