Page Sections

Ink Ridgeline Hero

Layered ink ridgelines are generated and drawn in one by one while the headline strokes in before filling, with the ridges separating in parallax as the page scrolls.

A landing-page hero section: the upper half holds an eyebrow, headline, subheading and two CTAs, and the lower half is a full-width range of procedurally generated ink ridges. The ridges are drawn in one layer at a time from far to near — far layers light, near layers dark, with a thin accent line on the nearest — and each layer gets a faint fill of the same color once its line is done. The headline first appears as outlined text, then fills solid from the bottom up as the first ridge finishes drawing. As the page scrolls, near layers rise more than far ones for a layered parallax. A different seed gives you a different mountain.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add ink-ridgeline-hero.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

7
5
1400
Active edited usageDefault props
<InkRidgelineHero/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/ink-ridgeline-hero.en.json

Or, once registries are configured in components.json, install it as @webberui/ink-ridgeline-hero.

Usage

import { InkRidgelineHero } from "@/components/ui/ink-ridgeline-hero";

<InkRidgelineHero
  eyebrow="Ridgeway Outdoors · Autumn Trail Guide"
  title="Give your weekend to the ridgeline"
  subtitle="Elevation, grade and water stops for 120 trails, condensed into one route card."
  seed={7}
  layers={5}
  primaryAction={{ label: "Find trails nearby", href: "/trails" }}
  secondaryAction={{ label: "See a sample route card", href: "#routes" }}
/>

Inside a nested scroll container

By default the component tracks window scroll. When it sits inside a fixed-height overflow-y-auto container, pass that container's ref to container. The ridge parallax follows how far the hero has scrolled inside the container.

const scrollerRef = React.useRef<HTMLDivElement>(null);

<div ref={scrollerRef} className="h-[400px] overflow-y-auto">
  <InkRidgelineHero container={scrollerRef} />
</div>

When the window itself is the scroll container, container can be omitted and behavior is unchanged.

Props

PropTypeDefaultDescription
titleReactNode—Headline (required); the stroke effect is visual only — the text is always real, readable text
eyebrowReactNode—Small label above the headline
subtitleReactNode—Subheading below the headline
primaryActionInkRidgelineHeroAction—Primary CTA (accent background)
secondaryActionInkRidgelineHeroAction—Secondary CTA (outlined)
as"h1" | "h2""h1"Heading level
minHeightnumber560Minimum section height (px)
seednumber7Deterministic seed for the ridge shapes; the same seed always draws the same mountains
layersnumber5Number of ridge layers, far to near, clamped to 3–7
align"left" | "center""left"Text alignment
strokeTitlebooleantrueHeadline appears as an outline first, then fills solid from the bottom up once the first ridge is drawn
drawnumber—Draw duration per ridge (ms), written to --wb-ink-ridgeline-hero-draw; falls back to the CSS variable when omitted (default 1400ms)
durationnumber—Text entrance duration (ms), written to --wb-ink-ridgeline-hero-duration; falls back to the CSS variable when omitted (default 600ms)
containerRefObject<HTMLElement>—Ref of the nested scroll container; omit to use the window as the scroll container
classNamestring—Class merged into the root section

InkRidgelineHeroAction

FieldTypeDefaultDescription
labelstring—Button text
hrefstring—Renders a link a when provided, otherwise a button
onClick(event: React.MouseEvent<HTMLElement>) => void—Click callback

How it works

  • Ridge shapes: each layer samples 52 points from a seed-driven 1D value noise (four octaves summed), then converts them into a smooth cubic Bézier via Catmull-Rom. The whole process is deterministic, so the server and the browser draw the same mountain and there is no hydration mismatch.
  • Drawing: each line is normalized with pathLength and animated from stroke-dashoffset 1 to 0 on cubic-bezier(0.65,0,0.35,1), 180ms apart per layer; the fill fades in over 500ms once the line is done.
  • Headline fill: -webkit-text-stroke draws the outline with transparent glyphs, then background-clip: text with an animated background-size fills it from the bottom up, timed to the moment the first ridge finishes.
  • Scroll parallax: useScroll tracks the section itself; the nearest layer rises to -60px and the farthest to -15px. Fills extend below the visible area so no gap opens at the bottom as they rise.
  • Overridable CSS variables: --wb-ink-ridgeline-hero-draw (draw duration), --wb-ink-ridgeline-hero-duration (text entrance duration), --wb-ink-ridgeline-hero-accent (accent, default #0d9488, #2dd4bf in dark mode).
  • Height comes from the content and minHeight; the component itself uses no viewport height units. On narrow screens the ridges crop at the sides instead of squashing the lines.

Accessibility

  • The root element is a section with aria-labelledby pointing at the headline; the heading level is set by as.
  • The ridge SVG is marked aria-hidden, so screen readers skip the decoration.
  • The headline's stroke and fill are purely visual styling — the text is always real text, so reading and selection are unaffected.
  • CTAs are an a or a type="button", with a clear focus-visible ring.
  • When the system "Reduce motion" setting is on, the ridges render as a complete composition without the layer-by-layer draw, the headline is solid from the start, the text entrance becomes a 150ms fade, and the scroll parallax is pinned to its end state; this applies from the very first paint via a CSS media query.