Path journey
A marker that travels along a path while the page scrolls, the path draws itself behind it and the stations on the way light up. A process, a delivery, a cycle or an ascent as one continuous way.
One of 3 components in Scroll journeys
Variants
Details
What Path journey brings along, read from the registry and the source.
- Category
- Scroll journeys
- Area
- Marketing
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/path-journey.tsx
- Brings along
- utilskiticonrevealsectionscroll-scene
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PathJourneyProps interface from components/ui/path-journey.tsx, with the comments from the code. Also takes every prop of Omit<React.HTMLAttributes<HTMLElement>, 'title'>.
variantoptionalPathJourneyVariantroutedeliverycycleascent
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringstationsrequiredPathStation[]markeroptionalIconNameThe marker icon, e.g. package for a delivery
heightsoptional[number, number]ascent: height of the start and of the top in metres
Built-in texts
Path journey writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| station | English Stop | German Station |
| of | English of | German von |
| height | English m | German m |
Accessibility
Read from the source of the component.
- Sets aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 447 lines
'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>
);
}Frequent questions
How do I install Path journey?
With aiw add @ai-website/path-journey. The command puts components/ui/path-journey.tsx into your project, the code is yours from then on. It brings along icon, reveal, section and scroll-scene. It needs no further npm packages.
Which variants does Path journey have?
4 variants, Winding route, Parcel on its way, Closed cycle and Ascent with height. You pick one with the variant prop, for example variant="delivery".
Can I translate the texts of Path journey?
Yes. Path journey carries 3 texts of its own in English and German, such as “Stop”, “of” and “m”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Path journey?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Path journey with aiw_components and install it with aiw_add @ai-website/path-journey. Name the variant in your prompt, for example variant="route", and the assistant writes your content straight into the props.
Can I adjust Path journey without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching PathJourney code to copy.