Brushstroke
React + Tailwind
import { useId } from 'react';
// One dry-brush stroke: a fat, slightly wavy band with tapered ends.
const STROKE = 'M4 27C20 8 62 6 106 9S178 6 196 15C201 21 199 34 192 41C152 51 98 46 56 48S12 50 4 40C0 36 0 31 4 27Z';
const css = `
.btn-brushstroke {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 200px;
height: 54px;
padding: 0;
border: none;
background: none;
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: 0.01em;
cursor: pointer;
}
.btn-brushstroke:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 10px; }
.btn-brushstroke__ink {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
clip-path: inset(0 100% 0 0);
transition: clip-path 0.55s cubic-bezier(0.6, 0, 0.2, 1);
}
.btn-brushstroke__ink path { fill: light-dark(#373a40, #f1f2f3); }
.btn-brushstroke__rule {
position: absolute;
left: 50%;
bottom: 10px;
width: 46px;
height: 1px;
margin-left: -23px;
background: light-dark(#5f626a, #a7abb2);
transition: opacity 0.3s;
}
.btn-brushstroke__label { position: relative; display: inline-flex; align-items: center; gap: 8px; transition: color 0.3s 0.1s; }
.btn-brushstroke__label svg { width: 15px; height: 15px; transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__ink { clip-path: inset(0 0 0 0); }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__rule { opacity: 0; }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__label { color: light-dark(#fbfcfd, #121417); }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__label svg { transform: translateX(3px); }
.btn-brushstroke:active .btn-brushstroke__ink { transform: scale(0.97); }
@media (prefers-reduced-motion: reduce) {
.btn-brushstroke__ink { transition: none; }
}
`;
export function BrushstrokeButton() {
const id = `btn-brushstroke-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
return (
<>
<style>
{css}
</style>
<button type="button" className="btn-brushstroke">
<svg className="btn-brushstroke__ink" viewBox="0 0 200 54" preserveAspectRatio="none" aria-hidden="true">
<defs>
{/* Bristle texture: the stroke's edge is pushed about by fine noise, so it reads as dry brush. */}
<filter id={id} x="-5%" y="-20%" width="110%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.9 0.08" numOctaves="2" seed="4" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="5" />
</filter>
</defs>
<path d={STROKE} filter={`url(#${id})`} />
</svg>
<span className="btn-brushstroke__rule" aria-hidden="true" />
<span className="btn-brushstroke__label">
Read the story
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h14m-5-5 5 5-5 5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<button type="button" class="btn-brushstroke">
<svg class="btn-brushstroke__ink" viewBox="0 0 200 54" preserveAspectRatio="none" aria-hidden="true">
<defs>
<filter id="btn-brushstroke-def" x="-5%" y="-20%" width="110%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.9 0.08" numOctaves="2" seed="4" result="n"></feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="n" scale="5"></feDisplacementMap>
</filter>
</defs>
<path d="M4 27C20 8 62 6 106 9S178 6 196 15C201 21 199 34 192 41C152 51 98 46 56 48S12 50 4 40C0 36 0 31 4 27Z" filter="url(#btn-brushstroke-def)"></path>
</svg>
<span class="btn-brushstroke__rule" aria-hidden="true"></span>
<span class="btn-brushstroke__label">Read the story<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14m-5-5 5 5-5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></svg></span>
</button>CSS
.btn-brushstroke {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 200px;
height: 54px;
padding: 0;
border: none;
background: none;
color: light-dark(#373a40, #f1f2f3);
font: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: 0.01em;
cursor: pointer;
}
.btn-brushstroke:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 10px; }
.btn-brushstroke__ink {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
clip-path: inset(0 100% 0 0);
transition: clip-path 0.55s cubic-bezier(0.6, 0, 0.2, 1);
}
.btn-brushstroke__ink path { fill: light-dark(#373a40, #f1f2f3); }
.btn-brushstroke__rule {
position: absolute;
left: 50%;
bottom: 10px;
width: 46px;
height: 1px;
margin-left: -23px;
background: light-dark(#5f626a, #a7abb2);
transition: opacity 0.3s;
}
.btn-brushstroke__label { position: relative; display: inline-flex; align-items: center; gap: 8px; transition: color 0.3s 0.1s; }
.btn-brushstroke__label svg { width: 15px; height: 15px; transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1); }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__ink { clip-path: inset(0 0 0 0); }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__rule { opacity: 0; }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__label { color: light-dark(#fbfcfd, #121417); }
.btn-brushstroke:is(:hover, :focus-visible) .btn-brushstroke__label svg { transform: translateX(3px); }
.btn-brushstroke:active .btn-brushstroke__ink { transform: scale(0.97); }
@media (prefers-reduced-motion: reduce) {
.btn-brushstroke__ink { transition: none; }
}Goal: Create a "Brushstroke" button.
Visual behavior: A link button that paints one dry-brush ink stroke behind its label on hover, left to right, while the thin rule beneath fades and the label turns to read over the ink.
Technique:
- The stroke is one filled SVG path, stretched to the button with preserveAspectRatio="none", under a filter of feTurbulence (stretched on X) and feDisplacementMap, so its edge reads as dry bristles.
- Hover and focus reveal it with a clip-path inset transition from the left; the label colour flips to read over it and the arrow nudges forward.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Keyboard focus paints the same stroke as hover, plus an outline. Under prefers-reduced-motion the stroke appears without sweeping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Brushstroke" button.
Visual behavior: A link button that paints one dry-brush ink stroke behind its label on hover, left to right, while the thin rule beneath fades and the label turns to read over the ink.
Technique:
- The stroke is one filled SVG path, stretched to the button with preserveAspectRatio="none", under a filter of feTurbulence (stretched on X) and feDisplacementMap, so its edge reads as dry bristles.
- Hover and focus reveal it with a clip-path inset transition from the left; the label colour flips to read over it and the arrow nudges forward.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Keyboard focus paints the same stroke as hover, plus an outline. Under prefers-reduced-motion the stroke appears without sweeping.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A link button that paints one dry-brush ink stroke behind its label on hover, left to right, while the thin rule beneath fades and the label turns to read over the ink.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, fill, draw
- Techniques
- svg filter, clip path, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. Keyboard focus paints the same stroke as hover, plus an outline. Under prefers-reduced-motion the stroke appears without sweeping.