motiv v0.2.0

Style 01 · drawn

Software that still looks like it was thought through on paper.

Pencil lines, ink ticks and a little wobble, on top of native inputs that behave exactly like the ones your users already know. Below is a whole settings screen made of nothing but drawn components. Click around, switch the theme, then take it apart.

sketchbook

Account

Make this sketchbook yours.

Profile complete

We only use it to reply.

Language
  • ENEnglish
  • DEDeutsch
  • UKУкраїнська
Read the whole screen, every line
import {
  Button,
  Card,
  CardBody,
  CardFooter,
  CardHeader,
  Checkbox,
  IconButton,
  IconButtonGroup,
  Link,
  MenuButton,
  NavItem,
  Progress,
  RadioGroup,
  SegmentedControl,
  Select,
  Tabs,
  TextField,
  ThemeToggle,
} from '@driif/motiv/drawn';
import { useState } from 'react';
import { useTheme } from '../useTheme';

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true">
      <path d={d} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

const languages = [
  { value: 'en', label: 'English', code: 'EN' },
  { value: 'de', label: 'Deutsch', code: 'DE' },
  { value: 'uk', label: 'Українська', code: 'UK' },
];

const digests = [
  { value: 'daily', label: 'Every morning' },
  { value: 'weekly', label: 'Once a week' },
  { value: 'never', label: 'Never, thanks' },
];

const densities = [
  { value: 'cozy', label: 'Cozy' },
  { value: 'compact', label: 'Compact' },
];

export function DrawnSettings() {
  const { dark, toggle } = useTheme();
  const [menu, setMenu] = useState(false);
  const [tab, setTab] = useState('profile');
  const [language, setLanguage] = useState('en');
  const [digest, setDigest] = useState('weekly');
  const [density, setDensity] = useState('cozy');
  const [name, setName] = useState('Ada Lovelace');
  const [newsletter, setNewsletter] = useState(false);
  const [saving, setSaving] = useState(false);

  const done = [name, language, newsletter, digest !== 'never'].filter(Boolean).length;

  return (
    <div className="site-app" data-menu={menu}>
      <header className="site-app__bar">
        <MenuButton className="site-app__menu" open={menu} onToggle={() => setMenu(!menu)} />
        <strong className="site-app__brand">sketchbook</strong>
        <ThemeToggle dark={dark} onToggle={toggle} />
      </header>
      <nav className="site-app__nav" aria-label="Sketchbook">
        <NavItem href="#settings" icon={<Icon d="M4 11l8-7 8 7v9h-5v-6H9v6H4z" />} label="Home" />
        <NavItem href="#settings" icon={<Icon d="M4 6h16v12H4zM4 6l8 7 8-7" />} label="Inbox" />
        <NavItem
          href="#settings"
          icon={<Icon d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />}
          label="Settings"
          active
        />
      </nav>
      <Card as="section" className="site-app__main" aria-label="Account settings">
        <CardHeader
          title="Account"
          titleAs="h2"
          description="Make this sketchbook yours."
          actions={
            <IconButtonGroup label="Avatar">
              <IconButton icon={<Icon d="M4 20l4-1 11-11-3-3L5 16l-1 4z" />} label="Edit avatar" />
              <IconButton
                icon={<Icon d="M5 7h14M9 7V4h6v3M7 7l1 13h8l1-13" />}
                label="Remove avatar"
              />
            </IconButtonGroup>
          }
        />
        <CardBody>
          <Progress className="site-app__progress" value={done * 25} label="Profile complete" />
          <Tabs
            label="Settings"
            value={tab}
            onChange={setTab}
            items={[
              {
                value: 'profile',
                label: 'Profile',
                panel: (
                  <div className="site-app__panel">
                    <TextField
                      label="Your name"
                      value={name}
                      onChange={(event) => setName(event.target.value)}
                    />
                    <TextField label="Email" type="email" hint="We only use it to reply." />
                    <Select
                      label="Language"
                      options={languages}
                      value={language}
                      onChange={setLanguage}
                    />
                  </div>
                ),
              },
              {
                value: 'letters',
                label: 'Letters',
                panel: (
                  <div className="site-app__panel">
                    <Checkbox
                      label="Send me the newsletter"
                      checked={newsletter}
                      onChange={(event) => setNewsletter(event.target.checked)}
                    />
                    <RadioGroup
                      name="digest"
                      legend="Activity digest"
                      options={digests}
                      value={digest}
                      onChange={setDigest}
                    />
                  </div>
                ),
              },
              {
                value: 'looks',
                label: 'Looks',
                panel: (
                  <div className="site-app__panel">
                    <SegmentedControl
                      label="Density"
                      options={densities}
                      value={density}
                      onChange={setDensity}
                    />
                    <p className="site-app__note">
                      Dark mode lives in the top bar. <Link href="#theme-toggle">How it works</Link>
                    </p>
                  </div>
                ),
              },
            ]}
          />
        </CardBody>
        <CardFooter>
          <Button variant="ghost">Cancel</Button>
          <Button
            loading={saving}
            onClick={() => {
              setSaving(true);
              setTimeout(() => setSaving(false), 1200);
            }}
          >
            Save changes
          </Button>
        </CardFooter>
      </Card>
    </div>
  );
}

Getting started

Four steps from empty folder to sketchbook.

  1. Install

    React 19 and react-dom are the only peers.

    pnpm add @driif/motiv
  2. Import the stylesheet once

    It carries the tokens, a small base and the Caveat font. Use it instead ofstyles.css, never next to it.

    import '@driif/motiv/drawn.css';
  3. Draw your first form

    Every component is a named export. No provider, no setup.

    import '@driif/motiv/drawn.css';
    import { Button, TextField } from '@driif/motiv/drawn';
    
    export function Subscribe() {
      return (
        <form>
          <TextField label="Email" type="email" hint="We only use it to reply." />
          <Button type="submit">Subscribe</Button>
        </form>
      );
    }
  4. Own the theme

    The toggles only report clicks. Your app sets data-themeon <html>; this is the hook this site uses.

    import { useSyncExternalStore } from 'react';
    
    // The library's toggles only report clicks; the app owns <html data-theme>.
    // Every toggle on the page reads the attribute, so they all stay in step.
    function subscribe(onChange: () => void) {
      const observer = new MutationObserver(onChange);
      observer.observe(document.documentElement, { attributeFilter: ['data-theme'] });
      return () => observer.disconnect();
    }
    
    export function useTheme() {
      const dark = useSyncExternalStore(
        subscribe,
        () => document.documentElement.dataset.theme === 'dark',
        () => false,
      );
      const toggle = () => {
        const theme = dark ? 'light' : 'dark';
        document.documentElement.dataset.theme = theme;
        localStorage.setItem('motiv-site-theme', theme);
      };
      return { dark, toggle };
    }

Button

The one thing on the page that should be pressed, cut by hand. Ghost buttons get a wavy stroke; links borrow the look.

A link, dressed up
import { Button, buttonClassName } from '@driif/motiv/drawn';

export function ButtonDemo() {
  return (
    <>
      <Button>Save</Button>
      <Button variant="outline">Preview</Button>
      <Button variant="ghost">Cancel</Button>
      <Button loading>Publishing</Button>
      <a href="#card" className={buttonClassName({ variant: 'secondary' })}>
        A link, dressed up
      </a>
    </>
  );
}

InkSplatterButton

A button that splashes ink when pressed, with an optional success label for a moment.

import { InkSplatterButton } from '@driif/motiv/drawn';

export function InkSplatterButtonDemo() {
  return (
    <InkSplatterButton
      successLabel="Liked"
      icon={
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth={1.8}
          aria-hidden="true"
        >
          <path
            d="M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.5A4 4 0 0 1 19 10c0 5.5-7 10-7 10z"
            strokeLinejoin="round"
          />
        </svg>
      }
    >
      Like
    </InkSplatterButton>
  );
}

HoldToConfirmButton

For things that are hard to undo: hold with pointer, Space or Enter until the ink fills; a screen-reader click confirms directly.

Sealed 0 times
import { HoldToConfirmButton } from '@driif/motiv/drawn';
import { useState } from 'react';

export function HoldToConfirmButtonDemo() {
  const [count, setCount] = useState(0);
  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
      <HoldToConfirmButton
        label="Hold to seal"
        confirmedLabel="Sealed"
        onConfirm={() => setCount(count + 1)}
      />
      <span>Sealed {count} times</span>
    </div>
  );
}

Card

A hand-cut sheet of paper with a heading, some words and a place for actions.

Account

Make this sketchbook yours.

Three new sketches, one finished page, a lot of coffee.
import {
  Button,
  Card,
  CardBody,
  CardFooter,
  CardHeader,
  IconButton,
  IconButtonGroup,
} from '@driif/motiv/drawn';

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true">
      <path d={d} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export function CardDemo() {
  return (
    <Card style={{ maxWidth: '28rem' }}>
      <CardHeader
        title="Account"
        description="Make this sketchbook yours."
        actions={
          <IconButtonGroup label="Account actions">
            <IconButton icon={<Icon d="M4 20l4-1 11-11-3-3L5 16l-1 4z" />} label="Edit" />
            <IconButton icon={<Icon d="M5 7h14M9 7V4h6v3M7 7l1 13h8l1-13" />} label="Delete" />
          </IconButtonGroup>
        }
      />
      <CardBody>Three new sketches, one finished page, a lot of coffee.</CardBody>
      <CardFooter>
        <Button variant="ghost" size="sm">
          Cancel
        </Button>
        <Button variant="outline" size="sm">
          Save changes
        </Button>
      </CardFooter>
    </Card>
  );
}

TextField

A pencil line to write on. Focus draws it in ink, the label floats, the hint stays put.

We only use it to reply.

import { TextField } from '@driif/motiv/drawn';

export function TextFieldDemo() {
  return (
    <>
      <TextField label="Your name" defaultValue="Ada Lovelace" />
      <TextField label="Email" type="email" hint="We only use it to reply." />
    </>
  );
}

Checkbox

A native checkbox, ticked by hand.

import { Checkbox } from '@driif/motiv/drawn';

export function CheckboxDemo() {
  return (
    <>
      <Checkbox label="Send me the newsletter" defaultChecked />
      <Checkbox label="Also the long one, on Sundays" />
    </>
  );
}

Radio & RadioGroup

One choice out of a few. RadioGroup wraps the fieldset and legend for you.

Drink
import { RadioGroup } from '@driif/motiv/drawn';
import { useState } from 'react';

const options = [
  { value: 'tea', label: 'Tea' },
  { value: 'coffee', label: 'Coffee' },
  { value: 'water', label: 'Water' },
];

export function RadioDemo() {
  const [drink, setDrink] = useState('tea');
  return (
    <RadioGroup name="drink" legend="Drink" options={options} value={drink} onChange={setDrink} />
  );
}

Select

A listbox with codes and hints, for when there are too many radios.

Language
  • ENEnglish
  • DEDeutsch
  • UKУкраїнська
import { Select } from '@driif/motiv/drawn';
import { useState } from 'react';

const options = [
  { value: 'en', label: 'English', code: 'EN', hint: 'Default language' },
  { value: 'de', label: 'Deutsch', code: 'DE', hint: 'German' },
  { value: 'uk', label: 'Українська', code: 'UK', hint: 'Ukrainian' },
];

export function SelectDemo() {
  const [language, setLanguage] = useState('');
  return <Select label="Language" options={options} value={language} onChange={setLanguage} />;
}

SegmentedControl

Two to four mutually exclusive views, side by side.

import { SegmentedControl } from '@driif/motiv/drawn';
import { useState } from 'react';

const options = [
  { value: 'day', label: 'Day' },
  { value: 'week', label: 'Week' },
  { value: 'month', label: 'Month' },
];

export function SegmentedControlDemo() {
  const [range, setRange] = useState('week');
  return <SegmentedControl label="Range" options={options} value={range} onChange={setRange} />;
}

Tabs

Panels behind labels, with arrow-key navigation.

A quick look at everything.
import { Tabs } from '@driif/motiv/drawn';
import { useState } from 'react';

const items = [
  { value: 'overview', label: 'Overview', panel: 'A quick look at everything.' },
  { value: 'activity', label: 'Activity', panel: 'What happened recently.' },
  { value: 'settings', label: 'Settings', panel: 'Tune it to taste.' },
];

export function TabsDemo() {
  const [tab, setTab] = useState('overview');
  return <Tabs label="Sections" items={items} value={tab} onChange={setTab} />;
}

IconButton & IconButtonGroup

A toolbar of sketched icons, each with a label for screen readers.

import { IconButton, IconButtonGroup } from '@driif/motiv/drawn';

function Icon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true">
      <path d={d} strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  );
}

export function IconButtonDemo() {
  return (
    <IconButtonGroup label="Tools">
      <IconButton icon={<Icon d="M4 20l4-1 11-11-3-3L5 16l-1 4z" />} label="Edit" />
      <IconButton icon={<Icon d="M12 5v14M5 12h14" />} label="Add" />
      <IconButton icon={<Icon d="M5 7h14M9 7V4h6v3M7 7l1 13h8l1-13" />} label="Delete" />
    </IconButtonGroup>
  );
}

Tooltip

A margin note behind an i. Hover, focus or click to read it, Escape to put it away.

Delivery takes three daysLeave a little room for the unexpected.

import { Tooltip } from '@driif/motiv/drawn';

export function TooltipDemo() {
  return (
    <p style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '5rem' }}>
      Delivery takes three days
      <Tooltip content="Leave a little room for the unexpected." label="About delivery" />
    </p>
  );
}

Progress

How far along something is, filled in with a marker.

Uploading
Done
import { Progress } from '@driif/motiv/drawn';

export function ProgressDemo() {
  return (
    <>
      <Progress value={62} label="Uploading" />
      <Progress value={100} label="Done" />
    </>
  );
}

ThemeToggle

A compact sun and moon. It reports clicks; your app sets data-theme.

import { ThemeToggle } from '@driif/motiv/drawn';
import { useTheme } from '../../useTheme';

export function ThemeToggleDemo() {
  const { dark, toggle } = useTheme();
  return <ThemeToggle dark={dark} onToggle={toggle} />;
}

OrbitalThemeToggle

The theme switch as a small illustration, for a hero or a settings page.

import { OrbitalThemeToggle } from '@driif/motiv/drawn';
import { useTheme } from '../../useTheme';

export function OrbitalThemeToggleDemo() {
  const { dark, toggle } = useTheme();
  return <OrbitalThemeToggle dark={dark} onToggle={toggle} />;
}