Three ways to get Pinned feature tour into one of your own projects, all pre-filled with this component's name and library page.
Start here
Component reference
Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.
Read it first
“Pinned feature tour” from my Astro Component Library:
https://astro.baysixmedia.com/components/pinned-feature-tour/
Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.
The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.
ChatGPT brief
Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.
Read it first
Help me add this component to one of my Astro websites:
Component: Pinned feature tour
Library page: https://astro.baysixmedia.com/components/pinned-feature-tour
I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]
Place it:
[ENTER LOCATION OR SELECTOR]
Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]
Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.
Placement prompt
Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.
Read it first
Add Pinned feature tour from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/pinned-feature-tour
First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.
Place it in:
[ENTER PAGE, FILE, OR ROUTE]
Position:
[ENTER WHERE IT SHOULD APPEAR]
Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.
Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.
src/components/library/PinnedFeatureTour.astro
This component is not one file on its own
assetsCopy these across too — they are referenced by URL, so nothing in the source above points at them: public/pinned-feature-tour/threaded-review-1000.webp, public/pinned-feature-tour/ranked-findings-1000.webp, public/pinned-feature-tour/ordered-queue-1000.webp, public/pinned-feature-tour/layered-branches-1000.webp, public/pinned-feature-tour/focused-surface-1000.webp. Leave them behind and those URLs resolve to nothing on the other end. What each component does without them is in its README.
---
/**
* Pinned feature tour
*
* A feature showcase that pins to the viewport while the page scrolls past it.
* Each scroll step advances to the next feature: its card expands to reveal a
* description and link, a progress rail fills alongside it, and the media panel
* cross-fades to that feature's artwork.
*
* Below the pin breakpoint — and whenever JavaScript has not run — the whole
* thing renders as a plain stacked list with every feature open. That is the
* fallback, not a degraded mode: all the content is present either way.
*
* Self-contained: no imports, no packages, no global stylesheet. Every colour
* and measurement is a `--ft-*` custom property on the root.
*/
export interface TourFeature {
/** Card title, and the accessible name of its step button. */
title: string;
/** Revealed when the step is active, and always shown when stacked. */
description?: string;
/** Artwork for this step. Omit for the built-in drawn placeholder. */
image?: string;
/** Alternative text. Empty means decorative. */
imageAlt?: string;
/** Optional call to action under the description. */
linkLabel?: string;
/** Destination. Without one the label is not rendered as a link. */
linkHref?: string;
}
interface Props {
/** Optional heading above the tour. `""` or omitted renders no header. */
heading?: string;
/** Optional line under the heading. */
intro?: string;
/** For pages where `h2` is the wrong level. */
headingLevel?: "h1" | "h2" | "h3";
/** The steps. Two or more is the point. */
features?: TourFeature[];
/** Which step is open before any scrolling has happened. 1-based. */
initialStep?: number;
/** Which side the media panel takes when pinned. */
mediaSide?: "start" | "end";
/** Pixels of page scroll each step owns. Larger is slower. */
scrollPerStep?: number;
/** Media / list split when pinned, as a percentage of the card. */
mediaWidth?: number;
/** Viewport inset above and below the pinned card, e.g. a fixed header. */
pinInset?: string;
/** Tallest the pinned card may get. */
cardMaxHeight?: string;
/** Rail fill, links and placeholder highlights. */
accent?: string;
/** Ground behind the whole section. */
background?: string;
/** The card and active step surface. */
surface?: string;
/** The media panel's ground. */
stage?: string;
/** Hairlines around the card, the panel and each step. */
border?: string;
/** Primary text colour. */
ink?: string;
/** Secondary text colour. */
muted?: string;
/** Corner radius of the outer card. */
radius?: string;
/** Caps the card width and centres it. */
maxWidth?: string;
/** Added to the root `<section>`. */
class?: string;
/** Root element id. Also seeds the step and panel ids. */
id?: string;
}
const {
heading = "",
intro = "",
headingLevel = "h2",
features = [
{
title: "Talk to the review, not around it",
description:
"Ask why a change was made and get an answer against the diff itself, with the surrounding context already loaded.",
linkLabel: "See how it reads",
},
{
title: "Signal first, everything else after",
description:
"Findings arrive ranked, so the one that will break production is not buried under forty notes about spacing.",
linkLabel: "See the ranking",
},
{
title: "Land changes in the order you meant",
description:
"A queue takes each approved change in turn, rebases it, and only merges once the branch is still green.",
linkLabel: "See the queue",
},
{
title: "Split the work, keep the thread",
description:
"Break one long change into a sequence of smaller ones that still read as a single story from top to bottom.",
linkLabel: "See the sequence",
},
{
title: "One surface for the whole change",
description:
"Comments, checks and the diff sit on one page, so nobody has to reconstruct the argument from three tabs.",
linkLabel: "See the surface",
},
],
initialStep = 1,
mediaSide = "start",
scrollPerStep = 540,
mediaWidth = 60,
pinInset = "40px",
cardMaxHeight = "720px",
accent = "#ff8833",
background = "#0a0a0b",
surface = "#141418",
stage = "#0c0c0e",
border = "#2a2a31",
ink = "#ffffff",
muted = "#a3a3ad",
radius = "16px",
maxWidth = "72rem",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
// One id prefix per instance, so several tours can share a page without their
// buttons and panels pointing at each other's elements.
const uid = id ?? `ft-${Math.random().toString(36).slice(2, 9)}`;
const count = Math.max(features.length, 1);
// 1-based in, 0-based out, clamped rather than trusted.
const initial = Math.min(Math.max(Math.trunc(initialStep), 1), count) - 1;
const listWidth = Math.min(Math.max(100 - mediaWidth, 20), 80);
const style = [
`--ft-accent:${accent}`,
`--ft-bg:${background}`,
`--ft-surface:${surface}`,
`--ft-stage:${stage}`,
`--ft-border:${border}`,
`--ft-ink:${ink}`,
`--ft-muted:${muted}`,
`--ft-radius:${radius}`,
`--ft-max:${maxWidth}`,
`--ft-pin-inset:${pinInset}`,
`--ft-card-max:${cardMaxHeight}`,
`--ft-media-w:${Math.min(Math.max(mediaWidth, 20), 80)}fr`,
`--ft-list-w:${listWidth}fr`,
`--ft-step:${scrollPerStep}px`,
`--ft-count:${count}`,
].join(";");
---
<section
class:list={["tour", className]}
id={id}
style={style}
data-ft-root
data-ft-initial={initial}
>
{
(heading || intro) && (
<header class="head">
{heading && <Heading class="heading">{heading}</Heading>}
{intro && <p class="intro">{intro}</p>}
</header>
)
}
{/* The runway is the scroll distance the pinned card consumes. Its height is
the viewport plus one step per feature, which is what turns page scroll
into step progress. Stacked, it collapses to auto. */}
<div class="runway" data-ft-runway>
<div class="pin" data-ft-pin>
<div class="card" data-media={mediaSide}>
{/* Media panel. Every step's artwork is stacked in one grid cell;
only the active one is opaque and at full scale. */}
<div class="stage">
<span class="dots" aria-hidden="true"></span>
{
features.map((feature, i) => (
<figure
class="shot"
id={`${uid}-shot-${i}`}
data-ft-shot
data-on={i === initial ? "" : null}
>
{feature.image ? (
<img
src={feature.image}
alt={feature.imageAlt ?? ""}
loading="lazy"
decoding="async"
/>
) : (
<span class="placeholder" aria-hidden="true">
<span class="ph-bar"></span>
<span class="ph-bar"></span>
<span class="ph-bar"></span>
</span>
)}
</figure>
))
}
</div>
<ol class="list" data-ft-list>
{
features.map((feature, i) => (
<li class="item" data-ft-item data-on={i === initial ? "" : null}>
{/* The rail sits outside the button so it can span the whole
card while the button's accessible name stays the title. */}
<span class="rail" aria-hidden="true">
<span class="fill"></span>
</span>
<h3 class="title">
<button
type="button"
class="jump"
data-ft-jump
data-index={i}
aria-controls={`${uid}-shot-${i}`}
aria-current={i === initial ? "step" : null}
>
{feature.title}
</button>
</h3>
<div class="body">
<div class="body-inner">
{feature.description && <p class="desc">{feature.description}</p>}
{feature.linkLabel &&
(feature.linkHref ? (
<a class="more" href={feature.linkHref}>
{feature.linkLabel}
<span class="arrow" aria-hidden="true">→</span>
</a>
) : (
/* No destination, so no link: a dead anchor that
looks clickable is worse than plain text. */
<span class="more is-static">{feature.linkLabel}</span>
))}
</div>
</div>
{/* Shown only when stacked, where there is no media panel. */}
<div class="inline-shot" aria-hidden={feature.image ? null : "true"}>
{feature.image ? (
<img
src={feature.image}
alt={feature.imageAlt ?? ""}
loading="lazy"
decoding="async"
/>
) : (
<span class="placeholder">
<span class="ph-bar"></span>
<span class="ph-bar"></span>
<span class="ph-bar"></span>
</span>
)}
</div>
</li>
))
}
</ol>
</div>
</div>
</div>
</section>
<style>
/* --- root and defensive resets ----------------------------------------- */
.tour {
box-sizing: border-box;
padding: clamp(32px, 5vw, 64px) clamp(16px, 4vw, 32px);
background: var(--ft-bg);
color: var(--ft-ink);
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
font-size: 16px;
line-height: 1.5;
text-align: start;
}
/* A destination site's global rules must not be able to move this
component's geometry, so every box it owns is reset rather than assumed. */
.tour *,
.tour *::before,
.tour *::after {
box-sizing: border-box;
}
/* `li` is deliberately absent: it has no default padding to reset, and
including it would out-specify `.item`'s own padding below. */
.tour h2,
.tour h3,
.tour p,
.tour figure,
.tour ol {
margin: 0;
padding: 0;
}
.tour li {
margin: 0;
}
.tour ol {
list-style: none;
}
.tour img {
display: block;
max-width: 100%;
}
/* --- header ------------------------------------------------------------- */
.head {
max-width: var(--ft-max);
margin: 0 auto clamp(24px, 3vw, 40px);
}
.heading {
font-size: clamp(1.75rem, 1.2rem + 2vw, 2.5rem);
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.02em;
}
.intro {
margin-top: 12px;
max-width: 52ch;
color: var(--ft-muted);
font-size: 1.0625rem;
}
/* --- stacked: the default, and the no-JS result ------------------------- */
.runway {
max-width: var(--ft-max);
margin-inline: auto;
}
.pin {
display: block;
}
.card {
display: grid;
gap: 16px;
padding: 16px;
border: 1px solid var(--ft-border);
border-radius: var(--ft-radius);
}
.stage {
display: none;
}
.list {
display: grid;
gap: 16px;
min-width: 0;
}
.item {
position: relative;
padding: 16px 16px 16px 32px;
border: 1px solid var(--ft-border);
border-radius: 10px;
background: var(--ft-surface);
min-width: 0;
}
.rail {
position: absolute;
top: 16px;
bottom: 16px;
left: 12px;
width: 4px;
border-radius: 999px;
background: var(--ft-border);
overflow: hidden;
}
.fill {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: var(--ft-accent);
}
.title {
font-size: 1.25rem;
font-weight: 500;
line-height: 1.4;
letter-spacing: -0.01em;
}
/* The button carries no chrome of its own: the card around it is the
affordance. It still has to be reset, or a host stylesheet's button rules
will repaint it. */
.jump {
appearance: none;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-align: inherit;
cursor: pointer;
}
.jump:focus-visible {
outline: 3px solid var(--ft-accent);
outline-offset: 4px;
border-radius: 3px;
}
.desc {
color: var(--ft-muted);
font-size: 1.0625rem;
line-height: 1.35;
}
.more {
display: inline-flex;
align-items: center;
gap: 0.4em;
margin-top: 14px;
color: var(--ft-accent);
font-size: 1rem;
text-decoration: none;
}
.more:hover .arrow {
transform: translateX(3px);
}
.more:focus-visible {
outline: 3px solid var(--ft-accent);
outline-offset: 3px;
border-radius: 3px;
}
.more.is-static {
color: var(--ft-muted);
cursor: default;
}
.arrow {
transition: transform 200ms ease-out;
}
.body-inner {
padding-top: 10px;
}
.inline-shot {
margin-top: 18px;
overflow: hidden;
aspect-ratio: 4 / 3;
border: 1px solid var(--ft-border);
border-radius: 10px;
background: var(--ft-stage);
}
.inline-shot img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Drawn, not loaded: a component with no artwork yet still has to look
deliberate rather than broken. */
.placeholder {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
width: 100%;
height: 100%;
padding: 14%;
background:
radial-gradient(
circle at 50% 0%,
color-mix(in srgb, var(--ft-accent) 18%, transparent),
transparent 70%
),
var(--ft-stage);
}
.ph-bar {
display: block;
height: 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--ft-ink) 16%, transparent);
}
.ph-bar:first-child {
width: 62%;
background: color-mix(in srgb, var(--ft-accent) 55%, transparent);
}
.ph-bar:nth-child(2) {
width: 90%;
}
.ph-bar:last-child {
width: 44%;
}
/* --- pinned: only once the script has run, and only when there is room --- */
@media (min-width: 768px) {
[data-ft-ready] .runway {
/* One viewport to pin in, plus one step of scroll per feature. */
height: calc(100vh + var(--ft-count) * var(--ft-step));
height: calc(100dvh + var(--ft-count) * var(--ft-step));
}
[data-ft-ready] .pin {
position: sticky;
top: 0;
display: flex;
align-items: center;
height: 100vh;
height: 100dvh;
padding-block: var(--ft-pin-inset);
}
[data-ft-ready] .card {
grid-template-columns: var(--ft-media-w) var(--ft-list-w);
align-items: stretch;
width: 100%;
max-height: var(--ft-card-max);
height: 100%;
}
[data-ft-ready] .card[data-media="end"] {
grid-template-columns: var(--ft-list-w) var(--ft-media-w);
}
[data-ft-ready] .card[data-media="start"] .stage {
grid-column: 1;
}
[data-ft-ready] .card[data-media="start"] .list {
grid-column: 2;
}
[data-ft-ready] .card[data-media="end"] .stage {
grid-column: 2;
}
[data-ft-ready] .card[data-media="end"] .list {
grid-column: 1;
}
[data-ft-ready] .stage {
position: relative;
display: grid;
grid-row: 1;
overflow: hidden;
min-width: 0;
padding: 6%;
border: 1px solid var(--ft-border);
border-radius: 10px;
background: var(--ft-stage);
}
[data-ft-ready] .dots {
position: absolute;
inset: 0;
opacity: 0.25;
background-image: radial-gradient(
circle,
color-mix(in srgb, var(--ft-ink) 45%, transparent) 1px,
transparent 1px
);
background-size: 22px 22px;
pointer-events: none;
}
/* Every shot occupies the same cell, so they cross-fade in place
instead of pushing each other around. */
[data-ft-ready] .shot {
grid-area: 1 / 1;
display: grid;
place-items: center;
min-width: 0;
opacity: 0;
transform: scale(0.8);
transition:
opacity 500ms cubic-bezier(0, 0, 0.2, 1),
transform 500ms cubic-bezier(0, 0, 0.2, 1);
pointer-events: none;
}
[data-ft-ready] .shot[data-on] {
opacity: 1;
transform: scale(1);
}
[data-ft-ready] .shot img,
[data-ft-ready] .shot .placeholder {
max-height: 100%;
width: auto;
max-width: 100%;
border-radius: 8px;
}
[data-ft-ready] .shot .placeholder {
width: 100%;
aspect-ratio: 4 / 3;
}
/* Flex, not grid, so the open step can take whatever height the collapsed
ones leave — which is what keeps the rail column flush with the media
panel beside it at any card height. */
[data-ft-ready] .list {
grid-row: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
[data-ft-ready] .item {
flex: 0 0 auto;
background: transparent;
overflow: hidden;
transition:
background-color 250ms ease-out,
border-color 250ms ease-out;
}
[data-ft-ready] .item[data-on] {
flex: 1 1 auto;
min-height: 0;
background: var(--ft-surface);
}
[data-ft-ready] .rail {
background: var(--ft-border);
}
[data-ft-ready] .item[data-on] .rail {
background: color-mix(in srgb, var(--ft-ink) 20%, transparent);
}
/* Height is driven from script as a 0–1 fraction of the step's scroll. */
[data-ft-ready] .fill {
height: calc(var(--ft-fill, 0) * 100%);
transition: height 250ms ease-out;
}
[data-ft-ready] .item:not([data-on]) .fill {
height: 0;
}
/* Collapsed steps keep their title and hide the rest, without the
description leaving the accessibility tree mid-animation. */
[data-ft-ready] .body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 250ms ease-out;
}
[data-ft-ready] .item[data-on] .body {
grid-template-rows: 1fr;
}
/* `visibility` and not just `overflow`: a collapsed step's description
and link are invisible, so they must leave the tab order and the
accessibility tree too, or Tab lands on a link nobody can see.
Transitioning visibility keeps it on screen for the whole collapse
and only then hides it. */
[data-ft-ready] .body-inner {
overflow: hidden;
padding-top: 0;
visibility: hidden;
transition: visibility 250ms;
}
[data-ft-ready] .item[data-on] .body-inner {
padding-top: 10px;
visibility: visible;
}
[data-ft-ready] .inline-shot {
display: none;
}
}
/* --- reduced motion ------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
.tour *,
.tour *::before,
.tour *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
/* The cross-fade is the motion, so it goes; the step still changes. */
[data-ft-ready] .shot {
transform: none;
}
[data-ft-ready] .shot[data-on] {
transform: none;
}
}
</style>
<script>
/**
* One controller per instance. Every element is looked up inside its own
* root, so any number of tours can share a page without touching each
* other's state.
*/
function initPinnedFeatureTour(root: HTMLElement) {
const runwayEl = root.querySelector<HTMLElement>("[data-ft-runway]");
const pinEl = root.querySelector<HTMLElement>("[data-ft-pin]");
const items = Array.from(root.querySelectorAll<HTMLElement>("[data-ft-item]"));
const shots = Array.from(root.querySelectorAll<HTMLElement>("[data-ft-shot]"));
const jumps = Array.from(root.querySelectorAll<HTMLButtonElement>("[data-ft-jump]"));
if (!runwayEl || !pinEl || items.length === 0) return;
// Re-bound after the guard so the narrowing survives into the handlers
// below, which TypeScript otherwise widens back to "possibly null".
const runway: HTMLElement = runwayEl;
const pin: HTMLElement = pinEl;
const count = items.length;
let current = Number(root.dataset.ftInitial ?? 0);
let frame = 0;
// Turning this on is what enables the pinned layout at all, so the
// stacked version is what renders if this script never runs.
root.setAttribute("data-ft-ready", "");
const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi);
/** Is the pinned layout actually in effect right now? */
const isPinned = () => getComputedStyle(pin).position === "sticky";
function paint(index: number, fill: number) {
if (index !== current) {
current = index;
items.forEach((item, i) => {
if (i === index) item.setAttribute("data-on", "");
else item.removeAttribute("data-on");
});
shots.forEach((shot, i) => {
if (i === index) shot.setAttribute("data-on", "");
else shot.removeAttribute("data-on");
});
jumps.forEach((jump, i) => {
if (i === index) jump.setAttribute("aria-current", "step");
else jump.removeAttribute("aria-current");
});
}
items[index].style.setProperty("--ft-fill", String(fill));
}
function measure() {
if (!isPinned()) {
// Stacked: the CSS shows everything, so nothing is driven here.
items.forEach((item) => item.style.removeProperty("--ft-fill"));
return;
}
const travel = runway.offsetHeight - pin.offsetHeight;
if (travel <= 0) return;
const scrolled = -runway.getBoundingClientRect().top;
const progress = clamp(scrolled / travel, 0, 1);
const raw = progress * count;
const index = clamp(Math.floor(raw), 0, count - 1);
paint(index, clamp(raw - index, 0, 1));
}
function schedule() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
measure();
});
}
/** Scroll the page to the point where `index` becomes the active step. */
function goTo(index: number) {
if (!isPinned()) {
items[index].scrollIntoView({ block: "center", behavior: "smooth" });
return;
}
const travel = runway.offsetHeight - pin.offsetHeight;
// Aim at the middle of the step's band, so a rounding error at the
// boundary cannot land on the neighbour.
const target =
window.scrollY +
runway.getBoundingClientRect().top +
((index + 0.5) / count) * travel;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
window.scrollTo({ top: target, behavior: reduce ? "auto" : "smooth" });
}
jumps.forEach((jump, i) => {
jump.addEventListener("click", () => goTo(i));
jump.addEventListener("keydown", (event) => {
let next: number;
switch (event.key) {
case "ArrowDown":
case "ArrowRight":
next = (i + 1) % count;
break;
case "ArrowUp":
case "ArrowLeft":
next = (i - 1 + count) % count;
break;
case "Home":
next = 0;
break;
case "End":
next = count - 1;
break;
default:
return;
}
event.preventDefault();
jumps[next].focus();
goTo(next);
});
});
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule, { passive: true });
measure();
}
document
.querySelectorAll<HTMLElement>("[data-ft-root]")
.forEach(initPinnedFeatureTour);
</script>