Drawably:手绘风格 UI 控件
Drawably: Hand-Drawn UI Controls

原始链接: https://github.com/Danilaa1/drawably

**Drawably** 是一个轻量级(9 KB JS,3 KB CSS)、零依赖的库,旨在为 Web 项目添加“手绘”风格的 UI 控件。它通过在挂载时生成全新的随机 SVG 手绘草图,并结合 CSS 驱动的细微“沸腾”动画效果来实现这一外观。 **主要特性:** * **全面的库:** 包含标准 UI 输入组件(按钮、复选框、开关、输入框)以及高级组件(卡片、标签页、工具提示、进度步骤)。 * **标注工具:** 可使用粗糙的下划线、高亮、圆圈和箭头来装饰文本。 * **框架友好:** 提供原生 DOM 集成以及全套 React 封装组件。 * **无障碍支持:** 保留原生 DOM 元素以支持屏幕阅读器和键盘操作;SVG 草图已设置 `aria-hidden`。 * **高度可定制:** 可通过 CSS 变量控制粗糙度、“沸腾”强度和颜色。使用可复现的 `seed`(种子值)可确保在重新渲染时保持样式一致。 * **注重性能:** 支持 `prefers-reduced-motion` 以冻结动画,且无需外部字体(但也提供了一个可选的 31 KB “Drawably Pen” 字体)。 该库还导出了原始的“粗糙”绘图函数,使开发者能够构建自定义的草图形状或将渲染器集成到独特的项目中。所有组件均采用 MIT 协议开源。

关于 UI 库“Drawably”(一个生成手绘风格控件的库),Hacker News 上的讨论呈现两极分化。 支持者欣赏这种适合制作“草稿”原型的美学风格,指出其“抖动”动画和独特的笔触有助于传达设计并非最终稿的信号——这在管理客户预期时非常有用。许多人将其与 Rough.js、Excalidraw 和 d2lang 的草图模式等现有工具进行了比较。 然而,批评者则表示怀疑。主要的质疑在于其标签具有误导性:虽然市场宣传为“手绘”,但用户指出这是机器生成的,因此呼吁提高透明度。其他人则认为这种特定的视觉风格(被形容为“墨迹斑斑”或类似于小学生字迹)缺乏吸引力。此外,关于字体选择也存在争议,一些人认为标准系统字体与草图风格的 UI 元素格格不入。 技术层面的担忧也被提出,特别是关于复杂页面上 CSS 动画的性能表现,以及为项目添加不必要依赖的潜在风险。归根结底,虽然有些人认为该库“很可爱”且适用于草稿板,但也有人将其视为对现有 SVG 技术过度设计的实现。
相关文章

原文

Hand-drawn UI controls. Every mount generates a fresh pen sketch from seeded randomness, and the stroke boils like an animated doodle. Zero dependencies, ~9 KB of JS gzipped (React wrappers add under 1 KB) and a 3 KB stylesheet. An optional pen font is a separate 31 KB.

Buttons, checkbox, radio and toggle drawn in a boiling pen stroke

import { drawablyButton } from "drawably";
import "drawably/style.css";

drawablyButton(document.querySelector("#done"), { variant: "solid" });

React:

import { DrawablyButton } from "drawably/react";
import "drawably/style.css";

<DrawablyButton variant="solid" onClick={submit}>Done</DrawablyButton>

Each attach call returns a sketch handle:

const sketch = drawablyButton(el);
sketch.resketch();     // redraw with a new random seed
sketch.resketch(42);   // redraw with a specific seed
sketch.destroy();      // remove the SVG and all listeners

Three variants: outline (default), solid, scribble. Buttons also carry a state machine for async work:

The four button states: idle, loading, error, success

const button = drawablyButton(el);
button.setState("loading");  // dims the button, boils faster
button.setState("error");    // redraws in red
button.setState("success");  // redraws in green
button.setState("idle");

In React, pass the state prop; the sketch stays put and only the state changes:

<DrawablyButton state={saving ? "loading" : "idle"}>Save</DrawablyButton>

Override the state colours with --drawably-error and --drawably-success. For secondary or destructive actions, set tone: "neutral" (warm grey) or tone: "danger" (red).

Function Element it expects
drawablyButton(el, opts) a <button>
drawablyCheckbox(el, opts) wrapper containing <input type="checkbox">
drawablyRadio(el, opts) wrapper containing <input type="radio">
drawablyToggle(el, opts) wrapper containing <input type="checkbox">
drawablyInput(el, opts) wrapper containing an <input>
drawablyTextarea(el, opts) wrapper containing a <textarea>
drawablySelect(el, opts) wrapper containing a <select>
drawablyDivider(el, opts) an <hr> or div
drawablyCard(el, opts) any block element
drawablyBadge(el, opts) any inline element
drawablyList(el, opts) a <ul> or <ol>; each <li> gets a sketched marker

Badges take variant: "outline" | "scribble"; lists take marker: "dash" | "check". Selects reserve the widest option's width so picking never shifts layout; in Chromium the options list gets a sketched frame and pen check (appearance: base-select), Safari and Firefox keep the OS popup.

The real inputs stay in the DOM, so keyboard, forms, labels and screen readers all work as usual. The sketch is an aria-hidden SVG layered underneath.

Every control has a React counterpart in drawably/react: DrawablyButton, DrawablyCheckbox, DrawablyRadio, DrawablyToggle, DrawablyInput, DrawablyTextarea, DrawablySelect, DrawablyDivider, DrawablyCard, DrawablyBadge, DrawablyList.

Annotate copy the way you would with a pen. Each attaches to an inline element and leaves its layout alone; use them on a word or a short phrase.

Function Draws
drawablyUnderline(el, opts) a rough line under the text, re-sketched on hover
drawablyHighlight(el, opts) a marker wash behind the text
drawablyCircle(el, opts) a hand-drawn ellipse looping around the text
drawablyArrow(from, to, opts) an arrow from one element to another
import { DrawablyUnderline, DrawablyHighlight, DrawablyCircle, DrawablyArrow } from "drawably/react";

<p>
  <DrawablyUnderline>Hand-drawn</DrawablyUnderline> UI, a{" "}
  <DrawablyHighlight>fresh sketch</DrawablyHighlight> on{" "}
  <DrawablyCircle>every mount</DrawablyCircle>.
</p>
<DrawablyArrow from={noteRef} to={buttonRef} />

A decoration that wraps onto several lines gets one drawing per line. The arrow's SVG is appended to <body> in document coordinates and redraws on resize. Anchors inside a scrolling container will drift as it scrolls.

Pieces built from the controls above. One seed reproduces every stroke in the piece; destroy() tears them all down.

Function Markup Draws
drawablyChip(el, opts) <label><span><input type="checkbox"></span> text</label> badge around the chip, sketched box on the input's wrapper
drawablyTabs(el, opts) children are the tabs underline on the active tab; active index or aria-selected="true"; setActive(i)
drawablyTooltip(tip, target, opts) two elements card around the tip, arrow to the target
drawablyAlert(el, opts) optional [data-tag] child card around the alert, badge on the tag
drawablySteps(el, opts) <ol> check-marked list
drawablyKbd(el, opts) <kbd> badge with a steadier hand
drawablyQuote(el, opts) first element child, optional <footer> highlight on the line, divider on the footer
drawablyPager(el, opts) child <button>s outlined pages, solid current; active index or aria-current; setPage(i)
import { DrawablyChip, DrawablyTabs, DrawablyTooltip, DrawablyAlert, DrawablySteps, DrawablyKbd, DrawablyQuote, DrawablyPager } from "drawably/react";

<DrawablyChip defaultChecked>pen</DrawablyChip>
<DrawablyTabs active={tab}><span>a</span><span>b</span></DrawablyTabs>
<DrawablyTooltip to={buttonRef}>undo</DrawablyTooltip>
<DrawablyAlert><span data-tag>new</span> Import from Attio lands Friday</DrawablyAlert>
<DrawablySteps><li>record</li><li>label</li></DrawablySteps>
<DrawablyKbd>⌘K</DrawablyKbd>
<DrawablyQuote><span>less, but better</span><footer>Rams</footer></DrawablyQuote>
<DrawablyPager active={page}><button></button><button>1</button><button></button></DrawablyPager>

All controls take the same base options:

Option Default What it does
seed random Omit for a unique sketch per mount, pass a number for a reproducible one
roughness 1 Wobble of the base sketch
boil 0.3 Px of frame-to-frame flicker; 0 renders one static path
stroke, fill, paper pen blue / white Colours, set as --drawably-* custom properties
width 2 Stroke width in px

The colours are plain CSS custom properties, so a theme can set them once:

:root {
  --drawably-stroke: #1a1a1a;
  --drawably-fill: #1a1a1a;
}

Type is Inter when the page has it loaded, falling back to system-ui. The library loads no font unless you opt into the one below.

Strokes boil gently: three frames of the same sketch, micro-wobbled around a shared base, cycled by pure CSS at 1200ms. Hover or press re-sketches buttons, checkboxes, radios, toggles, underlines and circles; buttons also lift on hover and sink on press. prefers-reduced-motion freezes everything to a single static sketch, including the demo images above.

Drawably Pen is the same strokes as a typeface: a–z, A–Z, digits and punctuation, built by the library's own pen code (font/) into a 31 KB TrueType. It is not loaded by style.css; nothing in the library needs it. If you want labels in the same hand as the chrome:

import "drawably/font.css";
.drawably-button {
  font-family: "Drawably Pen", Inter, sans-serif;
}

The rough renderer is exported. Each function returns an SVG path string, and variants produces the boil frames:

import { roughRoundedRect, roughLine, roughCircle, variants } from "drawably";

const frames = variants(
  (o) => roughRoundedRect(0, 0, 200, 100, 12, o),
  { seed: 7, roughness: 1, boil: 0.3 },
);
// three path strings — render them and cycle opacity

Also exported: roughEllipse, roughArrow, roughCheckmark, scribbleFill, and the seeded PRNG mulberry32 with randomSeed.

MIT.

联系我们 contact @ memedata.com