26 CSS Dividers26 / 26

Light JSMIT licensed

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.

Published Updated

Live Demo
Try it

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 &ldquo;hand-made&rdquo;.</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;
  }
}
(() => {
  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);
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
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 &ldquo;hand-made&rdquo;.</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 an feTurbulence + feDisplacementMap for 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-stroke a 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

ChromeSafariFirefoxEdge
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+.

Search CodeFronts

Loading…