26 / 26
All 26 in 26 CSS Dividers
- 01SVG Wave Section Divider CSS
- 02Diagonal Slanted Section Divider CSS
- 03Gradient Horizontal Rule CSS
- 04Text with Lines on Sides Divider CSS ("OR" Divider)
- 05Curved Arch Section Divider CSS
- 06Zigzag Torn Paper Cut Divider CSS
- 07Blob Organic SVG Divider CSS
- 08Pure CSS Ornamental Divider with Pseudo-Elements
- 09Editorial Article Serif Divider CSS
- 10Neo-Brutalist Heavy Border Divider CSS
- 11Art Deco Geometric Gold Divider CSS
- 12Organic Leaf & Vine SVG Divider CSS
- 13Cyberpunk Neon Glow Divider CSS
- 14Luxury Minimalist Gold Accent HR Line CSS
- 15Data Dashboard Dotted Grid Divider CSS
- 16Wedding Heart Icon Divider CSS
- 17Editorial Thin Double-Line Divider CSS
- 18Medieval Fantasy Star Crest Divider CSS
- 19Subtle Faded Border-Bottom Divider CSS
- 208-Bit Retro Pixelated Divider CSS
- 21Multi-Column Newspaper Rule Divider CSS
- 22Bouncing Animated Dots Divider CSS
- 23Audio Waveform SVG Divider CSS
- 24Confetti & Sparkle SVG Divider CSS
- 25Vertical Flexbox Divider Line CSS
- 26Hand-Drawn Doodle SVG Divider CSS
Hand-Drawn Doodle SVG Divider CSS
A wobbly marker line that draws itself when it scrolls into view — the signature move of personal blogs and illustrated portfolios. The path is deliberately imperfect, the stroke uses a round cap and a slight rotation so it never looks machined, and an IntersectionObserver measures the real path length with getTotalLength() so the animation is exact regardless of how you edit the curve.
Build interactive UI like this from scratch: 20 HTML, CSS & JavaScript projects
ZTM · JavaScript Web Projects · 30‑day money-back guarantee · Affiliate link
The code
<section class="div-26" aria-label="Hand-drawn doodle SVG divider demo">
<div class="div-26__stage" id="div-26-stage">
<p class="div-26__eyebrow">getTotalLength() · IntersectionObserver</p>
<h2 class="div-26__title">Drawn, not rendered</h2>
<div class="div-26__doodle" aria-hidden="true">
<svg viewBox="0 0 540 46" fill="none" focusable="false">
<path class="div-26__ghost" d="M14 30C74 12 118 38 176 24S296 8 350 26s112 2 176-8" stroke="currentColor" stroke-width="7" stroke-linecap="round"/>
<path class="div-26__ink" d="M12 27C72 9 116 35 174 21S294 5 348 23s112 2 178-9" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="div-26__sub">Two overlapping strokes, a round nib, and six tenths of a degree of tilt. That is the entire recipe for “hand-made”.</p>
<div class="div-26__doodle div-26__doodle--sm" aria-hidden="true">
<svg viewBox="0 0 300 30" fill="none" focusable="false">
<path class="div-26__ink" d="M10 18c26-11 44 8 68-1s40-9 62 2 46 4 70-6 44 7 68-2" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/>
</svg>
</div>
<p class="div-26__chip">stroke-dashoffset · round linecap · rotate(-0.6deg)</p>
</div>
</section><section class="div-26" aria-label="Hand-drawn doodle SVG divider demo">
<div class="div-26__stage" id="div-26-stage">
<p class="div-26__eyebrow">getTotalLength() · IntersectionObserver</p>
<h2 class="div-26__title">Drawn, not rendered</h2>
<div class="div-26__doodle" aria-hidden="true">
<svg viewBox="0 0 540 46" fill="none" focusable="false">
<path class="div-26__ghost" d="M14 30C74 12 118 38 176 24S296 8 350 26s112 2 176-8" stroke="currentColor" stroke-width="7" stroke-linecap="round"/>
<path class="div-26__ink" d="M12 27C72 9 116 35 174 21S294 5 348 23s112 2 178-9" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="div-26__sub">Two overlapping strokes, a round nib, and six tenths of a degree of tilt. That is the entire recipe for “hand-made”.</p>
<div class="div-26__doodle div-26__doodle--sm" aria-hidden="true">
<svg viewBox="0 0 300 30" fill="none" focusable="false">
<path class="div-26__ink" d="M10 18c26-11 44 8 68-1s40-9 62 2 46 4 70-6 44 7 68-2" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/>
</svg>
</div>
<p class="div-26__chip">stroke-dashoffset · round linecap · rotate(-0.6deg)</p>
</div>
</section>.div-26 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--div-26-bg);
--div-26-bg: oklch(0.975 0.016 95);
--div-26-ink: oklch(0.26 0.03 55);
--div-26-mut: oklch(0.54 0.03 55);
--div-26-acc: oklch(0.58 0.16 250);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--div-26-ink);
}
.div-26 *,
.div-26 *::before,
.div-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.div-26__stage {
min-height: 100svh;
display: grid;
place-content: center;
justify-items: center;
gap: clamp(16px,3vw,28px);
text-align: center;
padding: clamp(28px,6vw,72px) clamp(20px,5vw,56px);
background: radial-gradient(100% 70% at 50% 0%,oklch(0.995 0.01 95),transparent);
}
.div-26__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--div-26-acc);
}
.div-26__title {
font-family: Georgia,'Iowan Old Style',serif;
font-size: clamp(30px,6.2vw,58px);
line-height: 1.04;
letter-spacing: -.02em;
font-weight: 400;
}
.div-26__doodle {
width: min(92vw,540px);
line-height: 0;
color: var(--div-26-acc);
rotate: -.6deg;
}
.div-26__doodle--sm {
width: min(64vw,300px);
rotate: .5deg;
opacity: .65;
}
.div-26__doodle svg {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.div-26__ink,
.div-26__ghost {
vector-effect: non-scaling-stroke;
stroke-dasharray: var(--div-26-len,600);
stroke-dashoffset: var(--div-26-len,600);
}
.div-26__ghost {
opacity: .22;
}
.div-26 .is-drawn .div-26__ink {
animation: div-26-draw 1.5s cubic-bezier(.35,.6,.35,1) forwards;
}
.div-26 .is-drawn .div-26__ghost {
animation: div-26-draw 1.7s cubic-bezier(.35,.6,.35,1) .08s forwards;
}
@keyframes div-26-draw {
to {
stroke-dashoffset: 0;
}
}
.div-26__sub {
max-width: 44ch;
font-size: clamp(14px,1.85vw,17.5px);
line-height: 1.65;
color: var(--div-26-mut);
text-wrap: pretty;
}
.div-26__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--div-26-mut);
padding: 8px 14px;
border: 1px solid oklch(0.26 0.03 55/.18);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.div-26__ink,
.div-26__ghost {
animation: none !important;
stroke-dashoffset: 0;
}
}.div-26 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--div-26-bg);
--div-26-bg: oklch(0.975 0.016 95);
--div-26-ink: oklch(0.26 0.03 55);
--div-26-mut: oklch(0.54 0.03 55);
--div-26-acc: oklch(0.58 0.16 250);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--div-26-ink);
}
.div-26 *,
.div-26 *::before,
.div-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.div-26__stage {
min-height: 100svh;
display: grid;
place-content: center;
justify-items: center;
gap: clamp(16px,3vw,28px);
text-align: center;
padding: clamp(28px,6vw,72px) clamp(20px,5vw,56px);
background: radial-gradient(100% 70% at 50% 0%,oklch(0.995 0.01 95),transparent);
}
.div-26__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--div-26-acc);
}
.div-26__title {
font-family: Georgia,'Iowan Old Style',serif;
font-size: clamp(30px,6.2vw,58px);
line-height: 1.04;
letter-spacing: -.02em;
font-weight: 400;
}
.div-26__doodle {
width: min(92vw,540px);
line-height: 0;
color: var(--div-26-acc);
rotate: -.6deg;
}
.div-26__doodle--sm {
width: min(64vw,300px);
rotate: .5deg;
opacity: .65;
}
.div-26__doodle svg {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.div-26__ink,
.div-26__ghost {
vector-effect: non-scaling-stroke;
stroke-dasharray: var(--div-26-len,600);
stroke-dashoffset: var(--div-26-len,600);
}
.div-26__ghost {
opacity: .22;
}
.div-26 .is-drawn .div-26__ink {
animation: div-26-draw 1.5s cubic-bezier(.35,.6,.35,1) forwards;
}
.div-26 .is-drawn .div-26__ghost {
animation: div-26-draw 1.7s cubic-bezier(.35,.6,.35,1) .08s forwards;
}
@keyframes div-26-draw {
to {
stroke-dashoffset: 0;
}
}
.div-26__sub {
max-width: 44ch;
font-size: clamp(14px,1.85vw,17.5px);
line-height: 1.65;
color: var(--div-26-mut);
text-wrap: pretty;
}
.div-26__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--div-26-mut);
padding: 8px 14px;
border: 1px solid oklch(0.26 0.03 55/.18);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.div-26__ink,
.div-26__ghost {
animation: none !important;
stroke-dashoffset: 0;
}
}(() => {
const stage = document.getElementById('div-26-stage');
if (!stage || stage.dataset.ppWired) return;
stage.dataset.ppWired = '1';
const paths = stage.querySelectorAll('.div-26__ink, .div-26__ghost');
paths.forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 600);
p.style.setProperty('--div-26-len', len);
});
const calm = window.matchMedia('(prefers-reduced-motion: reduce)');
const draw = (el) => el.classList.add('is-drawn');
if (calm.matches || !('IntersectionObserver' in window)) { draw(stage); return; }
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
draw(e.target);
obs.unobserve(e.target);
});
}, { threshold: 0.25 });
stage.querySelectorAll('.div-26__doodle').forEach((d) => io.observe(d));
io.observe(stage);
})();(() => {
const stage = document.getElementById('div-26-stage');
if (!stage || stage.dataset.ppWired) return;
stage.dataset.ppWired = '1';
const paths = stage.querySelectorAll('.div-26__ink, .div-26__ghost');
paths.forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 600);
p.style.setProperty('--div-26-len', len);
});
const calm = window.matchMedia('(prefers-reduced-motion: reduce)');
const draw = (el) => el.classList.add('is-drawn');
if (calm.matches || !('IntersectionObserver' in window)) { draw(stage); return; }
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
draw(e.target);
obs.unobserve(e.target);
});
}, { threshold: 0.25 });
stage.querySelectorAll('.div-26__doodle').forEach((d) => io.observe(d));
io.observe(stage);
})();Here's a working CSS Divider from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Hand-Drawn Doodle SVG Divider CSS
Source: https://codefronts.com/snippets/css-dividers/sketch-dividers/
A wobbly marker line that draws itself when it scrolls into view — the signature move of personal blogs and illustrated portfolios. The path is deliberately imperfect, the stroke uses a round cap and a slight rotation so it never looks machined, and an IntersectionObserver measures the real path length with getTotalLength() so the animation is exact regardless of how you edit the curve.
## HTML
```html
<section class="div-26" aria-label="Hand-drawn doodle SVG divider demo">
<div class="div-26__stage" id="div-26-stage">
<p class="div-26__eyebrow">getTotalLength() · IntersectionObserver</p>
<h2 class="div-26__title">Drawn, not rendered</h2>
<div class="div-26__doodle" aria-hidden="true">
<svg viewBox="0 0 540 46" fill="none" focusable="false">
<path class="div-26__ghost" d="M14 30C74 12 118 38 176 24S296 8 350 26s112 2 176-8" stroke="currentColor" stroke-width="7" stroke-linecap="round"/>
<path class="div-26__ink" d="M12 27C72 9 116 35 174 21S294 5 348 23s112 2 178-9" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="div-26__sub">Two overlapping strokes, a round nib, and six tenths of a degree of tilt. That is the entire recipe for “hand-made”.</p>
<div class="div-26__doodle div-26__doodle--sm" aria-hidden="true">
<svg viewBox="0 0 300 30" fill="none" focusable="false">
<path class="div-26__ink" d="M10 18c26-11 44 8 68-1s40-9 62 2 46 4 70-6 44 7 68-2" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/>
</svg>
</div>
<p class="div-26__chip">stroke-dashoffset · round linecap · rotate(-0.6deg)</p>
</div>
</section>
```
## CSS
```css
.div-26 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--div-26-bg);
--div-26-bg: oklch(0.975 0.016 95);
--div-26-ink: oklch(0.26 0.03 55);
--div-26-mut: oklch(0.54 0.03 55);
--div-26-acc: oklch(0.58 0.16 250);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--div-26-ink);
}
.div-26 *,
.div-26 *::before,
.div-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.div-26__stage {
min-height: 100svh;
display: grid;
place-content: center;
justify-items: center;
gap: clamp(16px,3vw,28px);
text-align: center;
padding: clamp(28px,6vw,72px) clamp(20px,5vw,56px);
background: radial-gradient(100% 70% at 50% 0%,oklch(0.995 0.01 95),transparent);
}
.div-26__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--div-26-acc);
}
.div-26__title {
font-family: Georgia,'Iowan Old Style',serif;
font-size: clamp(30px,6.2vw,58px);
line-height: 1.04;
letter-spacing: -.02em;
font-weight: 400;
}
.div-26__doodle {
width: min(92vw,540px);
line-height: 0;
color: var(--div-26-acc);
rotate: -.6deg;
}
.div-26__doodle--sm {
width: min(64vw,300px);
rotate: .5deg;
opacity: .65;
}
.div-26__doodle svg {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.div-26__ink,
.div-26__ghost {
vector-effect: non-scaling-stroke;
stroke-dasharray: var(--div-26-len,600);
stroke-dashoffset: var(--div-26-len,600);
}
.div-26__ghost {
opacity: .22;
}
.div-26 .is-drawn .div-26__ink {
animation: div-26-draw 1.5s cubic-bezier(.35,.6,.35,1) forwards;
}
.div-26 .is-drawn .div-26__ghost {
animation: div-26-draw 1.7s cubic-bezier(.35,.6,.35,1) .08s forwards;
}
@keyframes div-26-draw {
to {
stroke-dashoffset: 0;
}
}
.div-26__sub {
max-width: 44ch;
font-size: clamp(14px,1.85vw,17.5px);
line-height: 1.65;
color: var(--div-26-mut);
text-wrap: pretty;
}
.div-26__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--div-26-mut);
padding: 8px 14px;
border: 1px solid oklch(0.26 0.03 55/.18);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.div-26__ink,
.div-26__ghost {
animation: none !important;
stroke-dashoffset: 0;
}
}
```
## JavaScript
```js
(() => {
const stage = document.getElementById('div-26-stage');
if (!stage || stage.dataset.ppWired) return;
stage.dataset.ppWired = '1';
const paths = stage.querySelectorAll('.div-26__ink, .div-26__ghost');
paths.forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 600);
p.style.setProperty('--div-26-len', len);
});
const calm = window.matchMedia('(prefers-reduced-motion: reduce)');
const draw = (el) => el.classList.add('is-drawn');
if (calm.matches || !('IntersectionObserver' in window)) { draw(stage); return; }
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
draw(e.target);
obs.unobserve(e.target);
});
}, { threshold: 0.25 });
stage.querySelectorAll('.div-26__doodle').forEach((d) => io.observe(d));
io.observe(stage);
})();
```Here's a working CSS Divider from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Hand-Drawn Doodle SVG Divider CSS
Source: https://codefronts.com/snippets/css-dividers/sketch-dividers/
A wobbly marker line that draws itself when it scrolls into view — the signature move of personal blogs and illustrated portfolios. The path is deliberately imperfect, the stroke uses a round cap and a slight rotation so it never looks machined, and an IntersectionObserver measures the real path length with getTotalLength() so the animation is exact regardless of how you edit the curve.
## HTML
```html
<section class="div-26" aria-label="Hand-drawn doodle SVG divider demo">
<div class="div-26__stage" id="div-26-stage">
<p class="div-26__eyebrow">getTotalLength() · IntersectionObserver</p>
<h2 class="div-26__title">Drawn, not rendered</h2>
<div class="div-26__doodle" aria-hidden="true">
<svg viewBox="0 0 540 46" fill="none" focusable="false">
<path class="div-26__ghost" d="M14 30C74 12 118 38 176 24S296 8 350 26s112 2 176-8" stroke="currentColor" stroke-width="7" stroke-linecap="round"/>
<path class="div-26__ink" d="M12 27C72 9 116 35 174 21S294 5 348 23s112 2 178-9" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="div-26__sub">Two overlapping strokes, a round nib, and six tenths of a degree of tilt. That is the entire recipe for “hand-made”.</p>
<div class="div-26__doodle div-26__doodle--sm" aria-hidden="true">
<svg viewBox="0 0 300 30" fill="none" focusable="false">
<path class="div-26__ink" d="M10 18c26-11 44 8 68-1s40-9 62 2 46 4 70-6 44 7 68-2" stroke="currentColor" stroke-width="3.4" stroke-linecap="round"/>
</svg>
</div>
<p class="div-26__chip">stroke-dashoffset · round linecap · rotate(-0.6deg)</p>
</div>
</section>
```
## CSS
```css
.div-26 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: block;
background: var(--div-26-bg);
--div-26-bg: oklch(0.975 0.016 95);
--div-26-ink: oklch(0.26 0.03 55);
--div-26-mut: oklch(0.54 0.03 55);
--div-26-acc: oklch(0.58 0.16 250);
font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
color: var(--div-26-ink);
}
.div-26 *,
.div-26 *::before,
.div-26 *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.div-26__stage {
min-height: 100svh;
display: grid;
place-content: center;
justify-items: center;
gap: clamp(16px,3vw,28px);
text-align: center;
padding: clamp(28px,6vw,72px) clamp(20px,5vw,56px);
background: radial-gradient(100% 70% at 50% 0%,oklch(0.995 0.01 95),transparent);
}
.div-26__eyebrow {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11.5px;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--div-26-acc);
}
.div-26__title {
font-family: Georgia,'Iowan Old Style',serif;
font-size: clamp(30px,6.2vw,58px);
line-height: 1.04;
letter-spacing: -.02em;
font-weight: 400;
}
.div-26__doodle {
width: min(92vw,540px);
line-height: 0;
color: var(--div-26-acc);
rotate: -.6deg;
}
.div-26__doodle--sm {
width: min(64vw,300px);
rotate: .5deg;
opacity: .65;
}
.div-26__doodle svg {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.div-26__ink,
.div-26__ghost {
vector-effect: non-scaling-stroke;
stroke-dasharray: var(--div-26-len,600);
stroke-dashoffset: var(--div-26-len,600);
}
.div-26__ghost {
opacity: .22;
}
.div-26 .is-drawn .div-26__ink {
animation: div-26-draw 1.5s cubic-bezier(.35,.6,.35,1) forwards;
}
.div-26 .is-drawn .div-26__ghost {
animation: div-26-draw 1.7s cubic-bezier(.35,.6,.35,1) .08s forwards;
}
@keyframes div-26-draw {
to {
stroke-dashoffset: 0;
}
}
.div-26__sub {
max-width: 44ch;
font-size: clamp(14px,1.85vw,17.5px);
line-height: 1.65;
color: var(--div-26-mut);
text-wrap: pretty;
}
.div-26__chip {
font-family: ui-monospace,Menlo,Consolas,monospace;
font-size: 11px;
color: var(--div-26-mut);
padding: 8px 14px;
border: 1px solid oklch(0.26 0.03 55/.18);
border-radius: 99px;
}
@media (prefers-reduced-motion: reduce) {
.div-26__ink,
.div-26__ghost {
animation: none !important;
stroke-dashoffset: 0;
}
}
```
## JavaScript
```js
(() => {
const stage = document.getElementById('div-26-stage');
if (!stage || stage.dataset.ppWired) return;
stage.dataset.ppWired = '1';
const paths = stage.querySelectorAll('.div-26__ink, .div-26__ghost');
paths.forEach((p) => {
const len = Math.ceil(p.getTotalLength() || 600);
p.style.setProperty('--div-26-len', len);
});
const calm = window.matchMedia('(prefers-reduced-motion: reduce)');
const draw = (el) => el.classList.add('is-drawn');
if (calm.matches || !('IntersectionObserver' in window)) { draw(stage); return; }
const io = new IntersectionObserver((entries, obs) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
draw(e.target);
obs.unobserve(e.target);
});
}, { threshold: 0.25 });
stage.querySelectorAll('.div-26__doodle').forEach((d) => io.observe(d));
io.observe(stage);
})();
```How this works
The measurement is what makes this robust. Instead of hard-coding a dasharray, read it from the path:
const len = path.getTotalLength();
path.style.setProperty('--len', len);
path.style.strokeDasharray = len;
path.style.strokeDashoffset = len;Then an IntersectionObserver flips a class when the divider enters the viewport, and CSS animates the offset to zero. Edit the path, and the animation stays perfect — no magic number to keep in sync.
The hand-drawn quality is three details, none of them random. First, stroke-linecap:round and stroke-linejoin:round, because a marker has a rounded nib. Second, a second, fainter path offset by 1–2px, which is what a real double-stroke looks like when you go over a line twice. Third, a slight rotate(-0.6deg) on the whole SVG — nothing drawn by hand is level, and that fraction of a degree is the difference between "sketchy" and "a wavy line made in code".
If you'd rather not ship JavaScript at all, the same effect is available with animation-timeline:view() in Chromium — the customisation note shows the three lines. The observer version is here because it works everywhere today.
Make it yours
- Pure-CSS scroll version:
animation:draw linear both; animation-timeline:view(); animation-range:entry 15% cover 45%— no JS, Chrome 115+, and it degrades to a static line elsewhere. - Marker texture: add
filter:url(#rough)with anfeTurbulence+feDisplacementMapfor genuine ink wobble along the stroke. - Underline a heading: shrink the viewBox and position the SVG absolutely under an inline heading — the classic hand-annotated emphasis.
- Draw an arrow or a circle: any single path works. Circling a word with a rough ellipse is the most-loved variant of this effect.
Gotchas — read before shipping
- getTotalLength() returns 0 if the SVG is display:none when measured. Measure after it is in the layout, or force a reflow first.
- Without
vector-effect:non-scaling-strokea responsive SVG scales its stroke width too, so the marker gets thick on desktop and thin on mobile. - IntersectionObserver fires once per threshold crossing — call
observer.unobserve()after the first draw or the line redraws every time it scrolls past. - Under prefers-reduced-motion, don't just shorten the animation: skip it and show the finished line, which is what the reduced-motion user actually wants.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.
IntersectionObserver: Chrome 51, Safari 12.1, Firefox 55. getTotalLength() and stroke-dash animation are universal. The optional animation-timeline:view() upgrade is Chrome 115+.