proof/docs/ink
ink

ink

Gives borders, shapes and headings a brush-drawn edge that redraws a few times a second, like hand-drawn animation. Text inside stays real and selectable.

  • 1.0 KB gzipped
  • 0 dependencies
  • 1 file
  • inspired by Rough.js

Try it

Tonight's sketch

The outline and the heading are inked. This sentence is left clean so it stays readable. Select it, then tab to the button.

Install

Download the file into your project and import it. There is no package and no build step, so the code is yours to change.

curl -O /proof/r/ink.js

Usage

example.js
import { ink } from './ink.js'

const card = ink(document.querySelector('.card'), { rough: 3 })

button.addEventListener('pointerenter', () => card.set({ bleed: 2.5 }))
button.addEventListener('pointerleave', () => card.set({ bleed: 0 }))

API

ink(el, options)

optiontypedefaultwhat it does
roughnumber3How far edges wander, in pixels.
bleednumber0How far ink spreads past the edge, in pixels.
boilbooleantrueRedraws the line about 8 times a second, like hand-drawn animation.
grainnumber0.035Size of the wobble. Lower values give longer, lazier strokes.

Returns

set({ rough, bleed, boil })Changes any option while the page runs.
destroy()Removes the filter and stops the boil.

Notes

  • Ink a frame or a heading, not body text. The filter softens everything inside the element, and Safari blurs small text.
  • The boil stops when the reader asks for reduced motion.

Source

The whole library. Read it, copy it, change it.

ink.js (1.0 KB gzipped)download
// ink.js from proof, by Alok Ranjan (https://alokranjan.me). MIT license.
const NS = 'http://www.w3.org/2000/svg'
let defs
let count = 0

function host() {
  if (defs) return defs
  const svg = document.createElementNS(NS, 'svg')
  svg.setAttribute('aria-hidden', 'true')
  svg.style.cssText = 'position:absolute;width:0;height:0'
  defs = document.createElementNS(NS, 'defs')
  svg.append(defs)
  document.body.append(svg)
  return defs
}

export function ink(el, { rough = 3, bleed = 0, boil = true, grain = 0.035 } = {}) {
  const id = 'ink-' + ++count
  const f = document.createElementNS(NS, 'filter')
  f.id = id
  f.setAttribute('x', '-5%')
  f.setAttribute('y', '-5%')
  f.setAttribute('width', '110%')
  f.setAttribute('height', '110%')
  f.innerHTML = `
    <feTurbulence type="fractalNoise" baseFrequency="${grain}" numOctaves="2" seed="1" result="t"/>
    <feDisplacementMap in="SourceGraphic" in2="t" scale="${rough}" xChannelSelector="R" yChannelSelector="G" result="d"/>
    <feMorphology in="d" operator="dilate" radius="${bleed}" result="m"/>
    <feGaussianBlur in="m" stdDeviation="0.7" result="b"/>
    <feComponentTransfer in="b"><feFuncA type="table" tableValues="0 0 0.9 1 1"/></feComponentTransfer>`
  host().append(f)
  el.style.filter = `url(#${id})`
  const turb = f.querySelector('feTurbulence')
  const disp = f.querySelector('feDisplacementMap')
  const morph = f.querySelector('feMorphology')
  let timer = 0
  const still = matchMedia('(prefers-reduced-motion: reduce)').matches
  const start = () => { if (!timer && !still) timer = setInterval(() => turb.setAttribute('seed', 1 + Math.floor(Math.random() * 3)), 130) }
  const stop = () => { clearInterval(timer); timer = 0 }
  if (boil) start()
  return {
    set({ rough: r, bleed: b, boil: on } = {}) {
      if (r !== undefined) disp.setAttribute('scale', r)
      if (b !== undefined) morph.setAttribute('radius', b)
      if (on === true) start()
      if (on === false) stop()
    },
    destroy() { stop(); f.remove(); el.style.filter = '' },
  }
}