@driif/motiv

中文文档
0.30.0发布于 2026/11/12MITTypeScriptES Module构建来源已验证近 30 天下载 5055

React UI components with plain, themeable CSS

原始使用文档
文档目录22 节
  1. Install
  2. Styles
  3. Usage
  4. Kinetic components
  5. Notifications
  6. Action menu
  7. Select
  8. Confirm
  9. Dialog
  10. Toast
  11. File uploads
  12. Dates
  13. Tables
  14. Markdown
  15. Drawn style
  16. Matrix style
  17. Theming
  18. Small layouts
  19. Astro
  20. Other frameworks
  21. Development
  22. Releasing

@driif/motiv

React components with plain, themeable CSS. ESM only, tree-shakeable, server-render safe.

Install

pnpm add @driif/motiv

react and react-dom 19+ are peer dependencies.

Styles

motiv comes in styles that share one component API. kinetic is the default: the root exports with styles.css. drawn is the hand-drawn style under @driif/motiv/drawn (see Drawn style). matrix is the phosphor-green terminal style under @driif/motiv/matrix (see Matrix style).

Import the stylesheet once, at the app's entry or base layout:

import '@driif/motiv/styles.css';
  • @driif/motiv/base.css — optional minimal reset and typography (box-sizing, body font and colors, heading font).
  • @driif/motiv/tokens.css — the design tokens alone, for apps that only want the variables.

Usage

import { Button, Card, CardBody, CardFooter, CardHeader } from '@driif/motiv';

<Card>
  <CardHeader title="Users" description="Active this week" />
  <CardBody>128 people signed in.</CardBody>
  <CardFooter>
    <Button variant="outline">Export</Button>
    <Button>View all</Button>
  </CardFooter>
</Card>;

Button takes variant (primary · secondary · outline · ghost · danger), size (sm · md · lg), iconLeft, iconRight, loading and every native <button> prop. It defaults to type="button".

Links stay the app's own — style them with buttonClassName:

import { buttonClassName } from '@driif/motiv';
import { Link } from 'react-router';

<Link to="/settings" className={buttonClassName({ variant: 'outline' })}>
  Settings
</Link>;

Kinetic components

Besides Button and Card, the root entry ships the kinetic set: MagneticButton, MorphSubmitButton, BellButton, NotificationList, Badge, BadgeButton, CopyField, RadialMenu, IconButton, IconButtonGroup, MenuButton, ThemeToggle, SegmentedControl, ElasticTabs, FilterChips, Radio, RadioGroup, Stepper, CommandMenu, FloatingField, OtpInput, ExpandingSearch, FrequencySlider, MomentumSlider, LiquidSwitch, SpringCheckbox, LikeButton, MuteToggle, SlipCard, Accordion, Carousel, GlideNav, StepProgress, DrillStack, ProgressRing and Odometer, plus the native Confirm and Dialog modals, FileUpload, DatePicker, Table with TableColumnsMenu, and MultiSelect, a searchable multiple choice whose name posts one hidden input per value. Every animation moves only transform and opacity; prefers-reduced-motion stills them. IconButton, MenuButton, ThemeToggle, Radio, RadioGroup and SegmentedControl take the same props as their drawn namesakes, so switching styles only changes the import. GlideNav items take an optional href and then render as <a>; onChange(id, event) lets a router preventDefault(). Server-rendered, the active item already shows its accent bar.

import { LiquidSwitch, SegmentedControl } from '@driif/motiv';

<SegmentedControl label="Range" options={ranges} value={range} onChange={setRange} />;
<LiquidSwitch label="Wi-Fi" checked={wifi} onChange={setWifi} />;

Notifications

NotificationList is a BellButton that opens a panel of notifications in a native popover="auto": click, Enter and Space toggle it, Escape and outside clicks close it, and focus returns to the bell. The app owns the items, their persistence and the unread count:

import { NotificationList } from '@driif/motiv';

<NotificationList
  items={items}
  count={unread}
  onDismiss={(id) => remove(id)}
  onDismissAll={clear}
  onOpen={(id) => navigate(`/inbox/${id}`)}
/>;

Each item takes id, title, time, optional text, tone (info, success, warning, danger; a decorative edge) and href. The title is a link when href is set, otherwise a button when onOpen is set; opening an item closes the panel. count defaults to items.length. label (Notifications, also the panel heading), newText, emptyText, dismissLabel and dismissAllLabel accept translated strings. Other button props and ref go to the bell. When a focused item is dismissed, focus moves to the next item, the previous one, or the heading. The panel does not announce new items; add your own live region or toast() for that.

Action menu

ActionMenu is a button that opens a menu of actions. It is built on Radix's dropdown menu, so it lives in its own entry and the root import never loads Radix. Its styles are already in @driif/motiv/styles.css:

import { ActionMenu } from '@driif/motiv/action-menu';

<ActionMenu
  label="Export"
  variant="outline"
  items={[
    { label: 'CSV', onSelect: exportCsv },
    { label: 'XLSX', onSelect: exportXlsx, disabled: true },
  ]}
/>;

label defaults to Actions. Every other prop (variant, size, loading, disabled, ref, native attributes) goes to the trigger Button. The trigger is disabled while loading, disabled or items is empty. The menu renders next to the trigger, not in a portal, so subtree themes and token overrides reach it.

Select

Select and TerminalSelect accept clearable to add a clear option. It calls onChange(''). Set clearLabel to change the option text; its default is Clear selection. An existing empty-value option is used as the clear option.

Confirm

Confirm is a controlled native modal <dialog>. Its action uses the danger variant by default; set confirmVariant="primary" for a non-destructive action. Impact counts and errors go in children.

import { Button, Confirm } from '@driif/motiv';
import { useState } from 'react';

function DeleteProject({ slug, deleteProject }: { slug: string; deleteProject: () => Promise<void> }) {
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState('');

  async function remove() {
    setPending(true);
    setError('');
    try {
      await deleteProject();
      setOpen(false);
    } catch {
      setError('Could not delete the project. Please try again.');
    } finally {
      setPending(false);
    }
  }

  return (
    <>
      <Button variant="danger" onClick={() => { setError(''); setOpen(true); }}>
        Delete project
      </Button>
      <Confirm
        open={open}
        title="Delete project?"
        slug={slug}
        pending={pending}
        onConfirm={remove}
        onCancel={() => setOpen(false)}
      >
        <p>This action cannot be undone.</p>
        <ul><li>3 bindings will also be deleted.</li></ul>
        {error && <p role="alert">{error}</p>}
      </Confirm>
    </>
  );
}

With slug, Delete stays disabled until the field matches exactly; the slug shows as the field's hint. confirmLabel (Delete), cancelLabel (Cancel) and slugLabel (Type the slug to confirm) take translated strings. pending locks both buttons, the field and Escape. The dialog never closes itself: Cancel and Escape call onCancel, the backdrop does nothing, and your app sets open={false}. Focus starts on Cancel or the field and returns to the opener on close.

Dialog

Dialog is a controlled native modal <dialog> for free content: a title, a close button and your children. Escape, the close button and a click on the backdrop call onClose; the dialog never closes itself. Focus returns to the opener, the page behind does not scroll, and the body scrolls within 90vh. size="wide" raises the width cap from 48rem to 60rem; closeLabel defaults to Close. Theme the backdrop with --motiv-dialog-backdrop.

import { Button, Dialog } from '@driif/motiv';
import { useState } from 'react';

function EditQuestion() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button onClick={() => setOpen(true)}>Edit question</Button>
      <Dialog open={open} title="Edit question" onClose={() => setOpen(false)}>
        <QuestionEditor onSaved={() => setOpen(false)} />
      </Dialog>
    </>
  );
}

Toast

Mount exactly one <Toaster /> per app (or one shared Astro island), then call toast() from any browser callback. No Provider is needed; a second host duplicates the same notifications. This API is available in the kinetic style only, with @driif/motiv/styles.css.

import { dismissToast, toast, Toaster } from '@driif/motiv';

<Toaster label="Notifications" dismissLabel="Dismiss" />;

toast({ tone: 'success', title: 'Changes saved', description: 'Your project is up to date.' });
const id = toast({ tone: 'error', title: 'Could not save', description: 'Please try again.' });
dismissToast(id);

tone defaults to info. Success and info dismiss after 5000 ms; errors stay until dismissed. duration overrides the time in milliseconds, with Infinity for persistence. Up to three toasts appear in arrival order, newest at the bottom; long stacks scroll within the viewport. Others wait until a slot opens after an exit; their timers start when shown. Mouse hover over the stack or focus inside pauses every visible timer, preserving the remaining time until both end. Touch pointers do not pause on hover. dismissToast(id) is safe to repeat.

Success and info use polite announcements; errors use an alert. New notifications never move focus. Dismissing a toast with keyboard-visible focus moves focus to another dismiss button, or back outside the stack. Pointer dismissal clears focus; remaining timers resume when the mouse no longer hovers the stack, or immediately after touch dismissal. label and dismissLabel accept translated strings; native section props and ref pass through. Server calls to toast() do nothing and return an empty string; server rendering produces an empty stack.

The stack uses fixed positioning below native modal dialogs. While Confirm is open, show errors that need attention in its existing children slot (for example, <p role="alert">), because the modal makes the rest of the page inert.

File uploads

The app supplies upload and obtains its own presigned URL. putFile sends the file body by XHR, reports progress as a percentage (0–100), and uses file.type for Content-Type unless contentType is supplied. Its headers must match those used when signing the URL.

import { FileUpload, putFile } from '@driif/motiv';

function Attachments({
  getUploadUrl,
}: {
  getUploadUrl: (file: File, signal: AbortSignal) => Promise<string>;
}) {
  return (
    <FileUpload
      multiple
      upload={async (file, { signal, onProgress }) => {
        const url = await getUploadUrl(file, signal);
        await putFile(url, file, { signal, onProgress });
      }}
    />
  );
}

Single selection replaces the previous entry; multiple appends. onUploaded(file) runs on success. onRemove(file) runs when a completed entry is removed or replaced; cancelling an upload only aborts its signal. Removing a failed entry does not call onRemove. onDiscard(file) reports a canceled, failed or replaced unfinished entry. It does not run on unmount.

Pass attachedFiles to show files the app already holds. Each item has id, name, onRemove, and optional size and onDownload. The app owns this list. When attachedFiles is supplied, a successful upload leaves the internal list; update attachedFiles from onUploaded to show it. The component does not match files by name, so two files can have the same name. Use formatDownloadLabel(name) and formatRemoveLabel(name) to translate the attachment action names.

Customize the picker, statuses and actions with label, uploadingLabel, uploadedLabel, errorLabel, retryLabel, removeLabel, cancelLabel and downloadLabel. errorMessage supplies the fallback when a rejection has no message; formatSize(bytes), formatUploadedMessage(name) and formatErrorMessage(name) customize sizes and live announcements. Native input props and ref reach the file input; className and style apply to the wrapper.

putFile rejects with English Error messages that take precedence over errorMessage; wrap the call in upload and rethrow with a localized message, or new Error('') to use the fallback.

The picker resets after each selection, so native required validation is unsupported; validate required attachments in the app using onUploaded and onRemove.

Dates

DatePicker wraps a native <input type="date"> or type="datetime-local" in the TextField look and adds a clear button while it holds a value. Values are the native strings, YYYY-MM-DD, YYYY-MM-DDTHH:mm (plus :ss for non-zero seconds) or '', and reach onChange, FormData and min/max unchanged.

import { DatePicker } from '@driif/motiv';

<DatePicker label="Due date" value={due} onChange={(event) => setDue(event.target.value)} />;
<DatePicker label="Starts" type="datetime-local" name="starts" min="2026-11-02T09:00" required />;

The browser draws the date format, the picker and its indicator; they follow the browser locale and differ between browsers. motiv uses no Date objects and converts no timezones: a datetime-local value is wall-clock time, and the app turns it into an instant.

Clearing calls onChange once with '' and focuses the input; the button is named clearLabel (default "Clear") plus the label. A partly typed date is still '', so the button appears once the date is complete. Errors work as on TextField: pass aria-invalid and show the message in hint or link it with aria-describedby. An empty required date turns red only after the user has interacted with it.

Tables

Table renders a native <table> from columns and rows. Each column has an id, a plain-text label (also the resize handle's name), an optional header node that replaces the label, for example a sort button, and a cell(row) renderer. Columns start at width (default 200px) and resize between minWidth (default 80px) and 800px by dragging a header edge or with the arrow keys; a double-click or Enter resets one. onColumnWidthsChange fires once per finished drag, key press or reset, never while dragging, so it can save to a server. Without columnWidths the table keeps the widths itself.

TableColumnsMenu only lists the columns: the app keeps hiddenColumns and filters columns before passing them to Table. Storing the hidden ids means columns added later show up.

import { Table, TableColumnsMenu } from '@driif/motiv';

<TableColumnsMenu columns={columns} hiddenColumns={hidden} onHiddenColumnsChange={setHidden} />;
<Table
  caption="Submitted forms"
  columns={columns.filter((column) => !hidden.includes(column.id))}
  rows={entries}
  getRowKey={(entry) => entry.id}
  columnWidths={widths}
  onColumnWidthsChange={setWidths}
  style={{ maxHeight: '32rem' }}
/>;

The table scrolls inside its wrapper. The header row only sticks once the wrapper has a height or max-height, as above. Cells stay on one line and end in an ellipsis.

For long lists, import DataTable from @driif/motiv/data-table. It takes the same props as Table plus maxHeight (default 70vh) and estimateRowHeight (default 40px). It mounts only the rows in view, measures each mounted row, and keeps a row with focus mounted when it scrolls out of view. Screen readers get the full count through aria-rowcount and aria-rowindex. Sorting, filtering and selection stay with the app, as with Table.

import { DataTable } from '@driif/motiv/data-table';

<DataTable caption="Rows" columns={columns} rows={rows} getRowKey={(row) => row.id} maxHeight="32rem" />;

Markdown

Import Markdown and MarkdownEditor from @driif/motiv/markdown, with @driif/motiv/styles.css. The parser dependencies stay out of the root JavaScript entry; importing only Markdown also leaves the editor out of the bundle.

import { Markdown, MarkdownEditor } from '@driif/motiv/markdown';
import '@driif/motiv/styles.css';

<Markdown>{'# Notes\n\nA **clear update** with [a link](/guide).'}</Markdown>;
<MarkdownEditor label="Notes" name="notes" defaultValue="## Draft" />;

Markdown renders CommonMark headings, lists, emphasis, quotes, links, images and code. Raw HTML stays text, and the generated HTML is sanitized. Links allow http, https, mailto, xmpp and relative destinations; unsafe protocols, including irc and ircs, are removed. Fenced code retains its language-* class without syntax highlighting. children defaults to an empty string; native div props and ref pass through, except dangerouslySetInnerHTML.

MarkdownEditor wraps Textarea with an always-visible preview. Use string value and native onChange for controlled editing, or defaultValue for uncontrolled editing with native form reset support. label defaults to Markdown and previewLabel to Preview; the preview does not announce each keystroke. Textarea props, including hint, rows, maxRows, name, disabled, readOnly and the textarea ref, pass through. className and style apply to the editor wrapper. The editor and preview stack in narrow containers. In Astro, add client:load to an editor island; Markdown can render as static HTML.

Drawn style

@driif/motiv/drawn is a hand-drawn style: Link, TextField, Checkbox, Radio, RadioGroup, Tabs, SegmentedControl, Select, NavItem, IconButton, IconButtonGroup, MenuButton, ThemeToggle, OrbitalThemeToggle and Progress. Its stylesheet is self-contained (tokens, base, drawn components and the bundled Caveat font), so a drawn app imports it instead of styles.css, never alongside it:

import '@driif/motiv/drawn.css';
import { Checkbox, Link, TextField } from '@driif/motiv/drawn';

<TextField label="Email" type="email" hint="We only use it to reply." />;
<Checkbox label="Send me the newsletter" />;
<Link href="https://example.com" external>
  Read the guide
</Link>;

Link takes arrow (default true), external (adds target="_blank" and rel="noopener noreferrer") and externalLabel (screen-reader text, default '(opens in a new tab)'). RadioGroup takes name, legend, options, value and onChange.

NavItem renders an <a> when given href (pass a router's onClick alongside), otherwise a <button>; active sets aria-current="page". MenuButton (open, onToggle) and ThemeToggle (dark, onToggle) are controlled; ThemeToggle never sets data-theme itself. Icons are app-supplied ReactNodes. IconButton takes shape="round" for a circular button in every style; the default stays square.

Matrix style

@driif/motiv/matrix is a monospace, phosphor-green terminal style: GlitchThemeToggle, ScanButton, TerminalSelect, DecodeSecretField, SignalBurger, HudTabs and ChevronNav, plus Button, buttonClassName, IconButton, IconButtonGroup, Card, CardHeader, CardBody, CardFooter, TextField, Checkbox, Radio, RadioGroup, Progress and Link with the same props as their drawn namesakes. Its stylesheet is self-contained, so a matrix app imports it instead of styles.css:

import '@driif/motiv/matrix.css';
import { DecodeSecretField, TerminalSelect } from '@driif/motiv/matrix';

<TerminalSelect label="Auth method" prefix="auth://" options={methods} value={method} onChange={setMethod} />;
<DecodeSecretField label="Root token" value={token} onChange={setToken} />;

TerminalSelect is a select-only combobox (arrows, Home, End, Enter, Space, Escape, letters); options take value, label, hint and an optional code shown in the trigger. DecodeSecretField and TerminalSelect report plain values through onChange(value); ChevronNav reports onChange(id, event), so a router can preventDefault() on href items. GlitchThemeToggle (dark, onToggle) and SignalBurger (open, onToggle) are controlled. Every visible string is a prop with an English default.

Theming

Everything is driven by --motiv-* custom properties, and all library CSS sits in @layer motiv.*, so unlayered app CSS wins without !important.

:root {
  --motiv-color-primary: #2563eb;
  --motiv-color-on-primary: #ffffff;
}

Dark mode: <html data-theme="dark">. Without data-theme, prefers-color-scheme applies. The Storybook Docs/Theming page lists every token and the component-level overrides.

Small layouts

Use 40rem as the small breakpoint. Below it, stack multi-column layouts. Use the same value in viewport or container queries:

@media (width < 40rem) {
  .layout {
    grid-template-columns: 1fr;
  }
}

@container (width < 40rem) {
  .layout {
    grid-template-columns: 1fr;
  }
}

Astro

Components render to static HTML, no client directive needed unless you attach handlers:

---
import '@driif/motiv/styles.css';
import { Button } from '@driif/motiv';
---
<Button variant="secondary">Static button</Button>

Import the stylesheet in the base layout so every page gets it.

Other frameworks

The look lives entirely in styles.css, so plain HTML, Vue or Svelte can use the classes directly:

<button type="button" class="motiv-btn motiv-btn--primary motiv-btn--md">
  <span class="motiv-btn__label">Save</span>
</button>

Development

pnpm check            # Biome
pnpm typecheck        # tsc
pnpm test             # Vitest
pnpm build            # dist/ (JS, d.ts, CSS)
pnpm check:package    # publint, attw, tree-shaking check
pnpm storybook        # docs on http://localhost:6006

Releasing

Every change a consuming app can see gets a changeset: run pnpm changeset and describe it in one line. On main, the release workflow opens a "Version Packages" PR; merging it publishes to npm.

版本与发布记录

@driif/motiv 共发布 35 个版本,首次发布于 2026/10/20;过去 12 个月发布了 35 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.30.0latest2026/11/121 天前
0.29.02026/11/112 天前
0.28.22026/11/112 天前
0.28.12026/11/112 天前
0.28.02026/11/112 天前
0.27.02026/11/112 天前

关于 @driif/motiv 的常见问题

@driif/motiv 是什么?

React UI components with plain, themeable CSS

如何安装 @driif/motiv?

在项目目录运行 npm install @driif/motiv,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(>=19)、react-dom(>=19)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@driif/motiv 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@driif/motiv 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@driif/motiv 可以商用吗?

@driif/motiv 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@driif/motiv 的下载量有多少?

根据 npm 官方下载统计,@driif/motiv 在 2026/10/13 – 2026/11/11 这 30 天内被下载 5,055 次,日均约 169 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@driif/motiv 还在维护吗?

最新版本 0.30.0 发布于 2026/11/12(1 天前);至今共发布 35 个版本,其中过去 12 个月发布了 35 个;首次发布于 2026/10/20。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。