Weg mit Stationen
Eine Markierung, die beim Scrollen einen Weg entlangfährt, der Weg zeichnet sich hinter ihr, und die Stationen leuchten auf. Ein Ablauf, eine Lieferung, ein Kreislauf oder ein Aufstieg als ein durchgehender Weg.
Eine von 3 Components in Scroll-Reisen
Variants
Details
Was Weg mit Stationen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Reisen
- Bereich
- Marketing
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/path-journey.tsx
- Bringt mit
- utilskiticonrevealsectionscroll-scene
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PathJourneyProps aus components/ui/path-journey.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalPathJourneyVariantroutedeliverycycleascent
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringstationsPflichtPathStation[]markeroptionalIconNameThe marker icon, e.g. package for a delivery
heightsoptional[number, number]ascent: height of the start and of the top in metres
Eingebaute Texte
Diese Texte schreibt Weg mit Stationen selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| station | Englisch Stop | Deutsch Station |
| of | Englisch of | Deutsch von |
| height | Englisch m | Deutsch m |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-live, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 447 Zeilen
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* A marker that travels along a path while the page scrolls, and the stations on the way light up.
* The path draws itself behind the marker. Explains a process, a delivery, a cycle or an ascent as
* one continuous way instead of a list.
*
* route a winding path through the section, cards left and right, measured from the layout
* delivery a parcel on its way from the shop to the door, the status changes at every stop
* cycle a marker on a closed ring, the station it reaches shows in the middle
* ascent a climb along a mountain profile, the height counts along
*
* Without JavaScript and with reduced motion the whole path is drawn, the marker is at the end and
* every station is lit.
*/
export type PathJourneyVariant = 'route' | 'delivery' | 'cycle' | 'ascent';
export interface PathStation {
title: string;
text?: string;
/** Small line above the title: a time, a day, a height */
label?: string;
icon?: IconName;
}
export interface PathJourneyProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: PathJourneyVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
stations: PathStation[];
/** The marker icon, e.g. package for a delivery */
marker?: IconName;
/** ascent: height of the start and of the top in metres */
heights?: [number, number];
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { station: 'Stop', of: 'of', height: 'm' },
de: { station: 'Station', of: 'von', height: 'm' },
};
type Pt = [number, number];
/** A smooth curve through the points (Catmull-Rom as cubic Bézier) */
function through(pts: Pt[], closed = false) {
if (pts.length < 2) return '';
const P = closed ? [pts[pts.length - 1], ...pts, pts[0], pts[1]] : [pts[0], ...pts, pts[pts.length - 1]];
let d = `M${round(P[1][0])} ${round(P[1][1])}`;
const end = closed ? P.length - 2 : P.length - 2;
for (let i = 1; i < end; i++) {
const [p0, p1, p2, p3] = [P[i - 1], P[i], P[i + 1], P[i + 2]];
const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += ` C${round(c1[0])} ${round(c1[1])} ${round(c2[0])} ${round(c2[1])} ${round(p2[0])} ${round(p2[1])}`;
}
return d + (closed ? 'Z' : '');
}
const N = 600;
/** The path sampled once at N + 1 even steps; the marker reads from here, never from the DOM while scrolling */
function samplesOf(path: SVGPathElement) {
const L = path.getTotalLength();
return Array.from({ length: N + 1 }, (_, i) => {
const q = path.getPointAtLength((i / N) * L);
return [q.x, q.y] as Pt;
});
}
/** The point at t (0 to 1) along the sampled path */
function pointAt(samples: Pt[], t: number): Pt {
const x = Math.min(N, Math.max(0, t * N));
const a = Math.floor(x);
const b = Math.min(N, a + 1);
const k = x - a;
return [samples[a][0] + (samples[b][0] - samples[a][0]) * k, samples[a][1] + (samples[b][1] - samples[a][1]) * k];
}
/** Where along the path (0 to 1) each point lies */
function fractionsOf(samples: Pt[], pts: Pt[]) {
return pts.map(([x, y]) => {
let best = 0;
let bd = Infinity;
samples.forEach(([sx, sy], i) => {
const dd = (sx - x) ** 2 + (sy - y) ** 2;
if (dd < bd) {
bd = dd;
best = i;
}
});
return best / N;
});
}
/**
* Moves the marker and draws the path up to it. Writes straight onto the elements, returns the
* index of the last station reached for the texts.
*/
function usePathMotion() {
const path = useRef<SVGPathElement | null>(null);
const marker = useRef<HTMLElement | SVGGElement | null>(null);
const len = useRef(0);
const fr = useRef<number[]>([]);
const samples = useRef<Pt[]>([]);
/** The last point the marker was moved to, in path units */
const at = useRef<Pt>([0, 0]);
// A path with non-scaling stroke counts its dashes in screen pixels: `px` is screen pixels per unit
const px = useRef(1);
const measure = useCallback((pts: Pt[], unit = 1) => {
const p = path.current;
if (!p) return;
len.current = p.getTotalLength();
px.current = unit;
samples.current = samplesOf(p);
fr.current = fractionsOf(samples.current, pts);
const L = round(len.current * unit + 2, 1);
p.style.strokeDasharray = `${L} ${L}`;
}, []);
const move = useCallback((t: number, scale: [number, number] = [1, 1]) => {
const p = path.current;
if (!p || !len.current) return -1;
p.style.strokeDashoffset = String(round((len.current * px.current + 2) * (1 - t), 1));
const q = pointAt(samples.current, t);
at.current = q;
if (marker.current) marker.current.style.transform = `translate(${round(q[0] * scale[0], 1)}px, ${round(q[1] * scale[1], 1)}px)`;
let reached = 0;
fr.current.forEach((f, i) => {
if (t >= f - 0.002) reached = i;
});
return reached;
}, []);
return { path, marker, measure, move, at };
}
function Head({ eyebrow, title, description, center }: Pick<PathJourneyProps, 'eyebrow' | 'title' | 'description'> & { center?: boolean }) {
if (!title && !eyebrow) return null;
return (
<div className={cn('max-w-[42rem]', center && 'mx-auto text-center')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
{description && <p className={cn('mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', center && 'mx-auto')}>{description}</p>}
</div>
);
}
function MarkerDot({ icon }: { icon: IconName }) {
return (
<span className="flex size-9 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-primary sm:size-11 text-primary-foreground shadow-[0_0_0_6px_color-mix(in_oklab,var(--primary)_22%,transparent),var(--shadow-md)]">
<Icon name={icon} size={19} />
</span>
);
}
/* ------------------------------------------------------------------ */
/* route: measured from the layout */
/* ------------------------------------------------------------------ */
function Route({ stations, live, marker }: { stations: PathStation[]; live: boolean; marker: IconName }) {
const box = useRef<HTMLDivElement>(null);
const { path, marker: mk, measure, move } = usePathMotion();
const [reached, setReached] = useState(stations.length - 1);
const [d, setD] = useState('');
const last = useRef(1);
const apply = useCallback(
(p: number) => {
last.current = p;
const r = move(p);
if (r >= 0) setReached(r);
},
[move],
);
const track = useSceneProgress<HTMLDivElement>(live, { mode: 'pass', self: false, onProgress: (p) => apply(Math.min(1, Math.max(0, (p - 0.2) / 0.55))) });
// The path runs through the dots of the stations, measured from the layout
useEffect(() => {
const el = box.current;
if (!el || !live) return;
const build = () => {
const b = el.getBoundingClientRect();
const pts = Array.from(el.querySelectorAll<HTMLElement>('[data-dot]')).map((dot) => {
const r = dot.getBoundingClientRect();
return [r.left + r.width / 2 - b.left, r.top + r.height / 2 - b.top] as Pt;
});
// Start a little above the first dot and end a little below the last
const all: Pt[] = [[pts[0][0], pts[0][1] - 60], ...pts, [pts[pts.length - 1][0], pts[pts.length - 1][1] + 40]];
setD(through(all));
requestAnimationFrame(() => {
measure(pts);
apply(last.current);
});
};
build();
const ro = new ResizeObserver(build);
ro.observe(el);
return () => ro.disconnect();
}, [live, measure, apply]);
return (
<div ref={track}>
<div ref={box} className="relative">
{live && d && (
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible">
<path d={d} fill="none" stroke="var(--border-strong)" strokeWidth="2" strokeDasharray="2 8" strokeLinecap="round" />
<path ref={path} d={d} fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" />
</svg>
)}
{!live && <span aria-hidden className="absolute bottom-6 left-[1.375rem] top-6 w-0.5 rounded-full bg-primary md:left-1/2 md:-translate-x-1/2" />}
<ol className="relative grid gap-10 md:gap-6">
{stations.map((s, i) => {
const on = !live || i <= reached;
const right = i % 2 === 1;
return (
<li key={i} className="grid grid-cols-[2.75rem_minmax(0,1fr)] items-center gap-4 md:grid-cols-[minmax(0,1fr)_6rem_minmax(0,1fr)] md:gap-0">
<span className={cn('relative z-10 flex justify-center md:col-start-2 md:row-start-1', right ? 'md:justify-end md:pr-2' : 'md:justify-start md:pl-2')}>
<span data-dot className={cn('flex size-11 items-center justify-center rounded-full border-2 bg-background text-[13px] font-semibold tabular-nums transition-colors duration-500', on ? 'border-primary text-foreground' : 'border-border-strong text-muted-foreground')}>
{s.icon ? <Icon name={s.icon} size={18} /> : i + 1}
</span>
</span>
<div className={cn('rounded-2xl border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] transition-[opacity,transform,border-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none sm:p-6 md:row-start-1', right ? 'md:col-start-3' : 'md:col-start-1 md:text-right', on ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] opacity-100' : 'translate-y-2 border-border opacity-45')}>
{s.label && <p className="text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{s.label}</p>}
<h3 className="mt-1 text-[17px] font-semibold text-foreground">{s.title}</h3>
{s.text && <p className={cn('mt-1.5 max-w-[26rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground', !right && 'md:ml-auto')}>{s.text}</p>}
</div>
</li>
);
})}
</ol>
{live && (
<span ref={mk as React.RefObject<HTMLSpanElement>} aria-hidden className="pointer-events-none absolute left-0 top-0 z-20 will-change-transform">
<MarkerDot icon={marker} />
</span>
)}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Pinned drawings: delivery, cycle, ascent */
/* ------------------------------------------------------------------ */
/** Fixed drawings in a 1000 × 600 field; the station points sit on the path */
const DELIVERY_WIDE: Pt[] = [[70, 420], [300, 300], [520, 410], [740, 250], [930, 330]];
const DELIVERY_TALL: Pt[] = [[200, 70], [320, 260], [110, 450], [300, 640], [200, 830]];
const ASCENT: Pt[] = [[60, 520], [260, 440], [430, 360], [600, 300], [760, 190], [930, 90]];
function cyclePts(n: number): Pt[] {
return Array.from({ length: n }, (_, i) => {
const a = -Math.PI / 2 + (i / n) * Math.PI * 2;
return [round(300 + 230 * Math.cos(a)), round(300 + 230 * Math.sin(a))] as Pt;
});
}
function Stage({ variant, stations, live, marker, heights, t }: { variant: 'delivery' | 'cycle' | 'ascent'; stations: PathStation[]; live: boolean; marker: IconName; heights: [number, number]; t: (typeof TEXTS)['en'] }) {
const locale = useLocale();
const n = stations.length;
const wide = useRef<HTMLDivElement>(null);
const motion = usePathMotion();
const [reached, setReached] = useState(n - 1);
const altitude = useRef<HTMLSpanElement>(null);
// Phones get the delivery drawn downwards; decided after mount, the server renders the wide one
const [narrow, setNarrow] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)');
const set = () => setNarrow(mq.matches);
set();
mq.addEventListener('change', set);
return () => mq.removeEventListener('change', set);
}, []);
const tall = live && variant === 'delivery' && narrow;
const pts = variant === 'delivery' ? (tall ? DELIVERY_TALL : DELIVERY_WIDE) : variant === 'cycle' ? cyclePts(n) : ASCENT;
const vb = variant === 'delivery' ? (tall ? [400, 900] : [1000, 500]) : variant === 'cycle' ? [600, 600] : [1000, 600];
const d = variant === 'cycle' ? through(pts, true) : through(pts);
const scale = useRef<[number, number]>([1, 1]);
const last = useRef(1);
const nf = useMemo(() => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US'), [locale]);
const fmt = (v: number) => nf.format(Math.round(v / 10) * 10);
const apply = useCallback(
(p: number) => {
last.current = p;
// Station i of n is reached at i / (n - 1) of the way (open paths) or i / n (the ring)
const r = motion.move(p, scale.current);
if (r >= 0) setReached(r);
if (altitude.current && variant === 'ascent' && r >= 0) altitude.current.textContent = fmt(heights[0] + ((520 - motion.at.current[1]) / 430) * (heights[1] - heights[0]));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[motion, variant, heights, nf],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
useEffect(() => {
if (!live) return;
const el = wide.current;
if (!el) return;
const size = () => {
scale.current = [el.clientWidth / vb[0], el.clientHeight / vb[1]];
// the ring closes on its start, so its last station is a stop before the end
motion.measure(pts, scale.current[0]);
apply(last.current);
};
size();
const ro = new ResizeObserver(size);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [live, tall]);
const active = live ? reached : n - 1;
const endPt = variant === 'cycle' ? pts[0] : pts[pts.length - 1];
const drawing = (
<div ref={wide} className={cn('relative w-full', variant === 'delivery' ? (tall ? 'aspect-[4/9] max-h-[52svh]' : 'aspect-[2/1]') : variant === 'cycle' ? 'mx-auto aspect-square max-w-[min(34rem,56svh)]' : 'aspect-[5/3]')} style={variant === 'delivery' && tall ? { maxWidth: 'calc(52svh * 4 / 9)', marginInline: 'auto' } : undefined}>
<svg viewBox={`0 0 ${vb[0]} ${vb[1]}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible" aria-hidden>
{variant === 'ascent' && <path d={`${d} L1000 600 L0 600Z`} fill="color-mix(in oklab, var(--primary) 10%, transparent)" />}
{variant === 'ascent' && <path d="M0 600L140 520L260 560L430 450L560 500L720 380L860 430L1000 330V600Z" fill="color-mix(in oklab, var(--foreground) 5%, transparent)" />}
{variant === 'delivery' && <path d={d} fill="none" stroke="color-mix(in oklab, var(--foreground) 8%, transparent)" strokeWidth="34" strokeLinecap="round" vectorEffect="non-scaling-stroke" />}
<path d={d} fill="none" stroke="var(--border-strong)" strokeWidth="2" strokeDasharray="2 8" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
<path ref={motion.path} d={d} fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
</svg>
{/* Stations as HTML on top, so they keep their shape at any aspect ratio */}
{pts.map(([x, y], i) => {
const on = i <= active;
const below = variant === 'cycle' ? y > 300 : variant === 'delivery' && !tall ? i % 2 === 0 : false;
return (
<div key={i} className="absolute" style={{ left: `${round((x / vb[0]) * 100, 3)}%`, top: `${round((y / vb[1]) * 100, 3)}%` }}>
<span className={cn('absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-colors duration-500', on ? 'border-primary bg-primary' : 'border-border-strong bg-background')} />
{variant !== 'cycle' && (
<span className={cn('absolute -translate-x-1/2 whitespace-nowrap text-[12.5px] font-medium transition-colors duration-500', below ? 'top-4' : '-top-9', tall && 'left-6 top-[-0.6rem] translate-x-0', !live && 'max-md:hidden', on ? 'text-foreground' : 'text-muted-foreground')}>{stations[i]?.label ?? stations[i]?.title}</span>
)}
</div>
);
})}
{live ? (
<span ref={motion.marker as React.RefObject<HTMLSpanElement>} aria-hidden className="pointer-events-none absolute left-0 top-0 z-10 will-change-transform">
<MarkerDot icon={marker} />
</span>
) : (
<span aria-hidden className="pointer-events-none absolute z-10" style={{ left: `${round((endPt[0] / vb[0]) * 100, 3)}%`, top: `${round((endPt[1] / vb[1]) * 100, 3)}%` }}>
<MarkerDot icon={marker} />
</span>
)}
{variant === 'cycle' && (
<div className="absolute inset-[24%] flex flex-col items-center justify-center text-center" aria-live="polite">
<p className="text-[12.5px] font-medium tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{t.station} {active + 1} {t.of} {n}
</p>
<div key={active} className="kit-rise">
<h3 className="mt-2 text-balance text-[clamp(1.0625rem,0.9rem+0.6vw,1.375rem)] font-semibold leading-tight text-foreground">{stations[active]?.title}</h3>
{stations[active]?.text && <p className="mt-2 hidden text-pretty text-[13.5px] leading-relaxed text-muted-foreground sm:block">{stations[active].text}</p>}
</div>
</div>
)}
{variant === 'ascent' && (
<p className="absolute left-0 top-0 rounded-lg border border-border bg-card px-3 py-2 text-[13px] text-muted-foreground shadow-[var(--shadow-sm)]">
<span ref={altitude} className="text-[20px] font-semibold tabular-nums text-foreground">
{fmt(heights[1])}
</span>{' '}
{t.height}
</p>
)}
</div>
);
const status = variant !== 'cycle' && (
<div className="min-h-[5.5rem]" aria-live="polite">
<div key={active} className={cn(live && 'kit-rise')}>
<p className="flex items-center gap-2 text-[12.5px] font-medium tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{stations[active]?.icon && <Icon name={stations[active].icon!} size={15} />}
{t.station} {active + 1} {t.of} {n}
</p>
<h3 className="mt-1.5 text-[18px] font-semibold text-foreground">{stations[active]?.title}</h3>
{stations[active]?.text && <p className="mt-1 max-w-[34rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{stations[active].text}</p>}
</div>
</div>
);
if (!live) {
return (
<div className="grid gap-10">
{drawing}
<ol className={cn('grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-2', n >= 5 ? 'lg:grid-cols-5' : n === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
{stations.map((s, i) => (
<li key={i} className="bg-card p-5">
{s.label && <p className="text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">{s.label}</p>}
<h3 className="mt-1 text-[15.5px] font-semibold text-foreground">{s.title}</h3>
{s.text && <p className="mt-1.5 text-pretty text-[14px] leading-relaxed text-muted-foreground">{s.text}</p>}
</li>
))}
</ol>
</div>
);
}
return (
<div ref={track} className="relative" style={{ height: `${round(Math.max(2.6, n * 0.6) * 100, 2)}svh` }}>
<div className="sticky top-0 flex h-svh flex-col justify-center gap-6 overflow-clip py-6">
{drawing}
{status}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/** A marker that travels along a path while the page scrolls. */
export function PathJourney({ variant = 'route', eyebrow, title, description, stations, marker, heights = [1200, 2900], labels, className, ...rest }: PathJourneyProps) {
const t = useTexts(TEXTS, labels);
const live = useLive();
const icon: IconName = marker ?? (variant === 'delivery' ? 'package' : variant === 'ascent' ? 'mountain' : variant === 'cycle' ? 'refresh' : 'navigation');
return (
<section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-28', live && variant !== 'route' && 'py-0 sm:py-0 lg:py-0', className)}>
<div className="kit-container">
{variant === 'route' ? (
<>
<Reveal className="mb-14 sm:mb-16">
<Head eyebrow={eyebrow} title={title} description={description} center />
</Reveal>
<Route stations={stations} live={live} marker={icon} />
</>
) : (
<>
{!live && (
<Reveal className="mb-12">
<Head eyebrow={eyebrow} title={title} description={description} />
</Reveal>
)}
{live && (
<div className="pb-4 pt-20 sm:pt-24">
<Head eyebrow={eyebrow} title={title} description={description} />
</div>
)}
<Stage variant={variant} stations={stations} live={live} marker={icon} heights={heights} t={t} />
</>
)}
</div>
</section>
);
}Häufige Fragen
Wie installiere ich Weg mit Stationen?
Mit aiw add @ai-website/path-journey. Der Befehl legt die Datei components/ui/path-journey.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, reveal, section und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Weg mit Stationen?
4 Varianten, Gewundener Weg, Paket unterwegs, Geschlossener Kreislauf und Aufstieg mit Höhe. Du wählst sie mit der Prop variant, zum Beispiel variant="delivery".
Kann ich die Texte von Weg mit Stationen übersetzen?
Ja. Weg mit Stationen bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Station“, „von“ und „m“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Weg mit Stationen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Weg mit Stationen mit aiw_components und installieren sie mit aiw_add @ai-website/path-journey. Nenn im Prompt die Variante, etwa variant="route", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Weg mit Stationen ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden PathJourney-Code zum Kopieren.