Sticky scroll
Two columns: one stays put and shows a picture or a large heading, and changes as each step's copy in the other column scrolls past. One column on narrow screens.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add sticky-scrollpnpm dlx ermineui add sticky-scrollyarn dlx ermineui add sticky-scrollbunx ermineui add sticky-scrollThat copies 4 files into src/components/ui/sticky-scroll/, 1 helper into src/lib/ermine/. Files you already have are kept.
Use
import { StickyScroll, StickyScrollItem } from '@/components/ui/sticky-scroll'<StickyScroll top="3rem">
<StickyScrollItem heading="Copy it in.">
<p>One command puts a component's source in your project.</p>
</StickyScrollItem>
<StickyScrollItem image="/screens/themes.png" alt="A page with a red accent">
<h3>Themes to start from</h3>
<p>Pick a base and an accent.</p>
</StickyScrollItem>
<StickyScrollItem :span="3">
<template #media="{ progress }">
<Chart :data="days" x="day" :series="[{ key: 'owed' }]" label="What the account still owed" :reveal="progress" />
</template>
<h3>Paid in 39 days</h3>
<p>The chart draws itself while this copy stays beside it.</p>
</StickyScrollItem>
</StickyScroll>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
StickyScroll.vue | src/components/ui/sticky-scroll/StickyScroll.vue |
StickyScrollItem.vue | src/components/ui/sticky-scroll/StickyScrollItem.vue |
context.ts | src/components/ui/sticky-scroll/context.ts |
index.ts | src/components/ui/sticky-scroll/index.ts |
context.ts helper | src/lib/ermine/context.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
StickyScroll.vue 120 lines
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { provideStickyScroll } from './context'
const props = withDefaults(
defineProps<{
/** Which column stays put: the media at the start (left) or at the end (right). */
side?: 'start' | 'end'
/** How far from the top of the window the media sticks, e.g. the height of a sticky header: px, or any CSS length. */
top?: number | string
}>(),
{ side: 'start', top: 0 },
)
/** Index of the step whose media is showing. It follows the scroll, and can be set to show a step. */
const active = defineModel<number>('active', { default: 0 })
const steps = shallowRef<HTMLElement[]>([])
const ready = ref(false)
const progress = ref<number[]>([])
let observer: IntersectionObserver | undefined
let frame = 0
function register(copy: HTMLElement) {
// Kept in page order, whatever order the steps mount in.
steps.value = [...steps.value, copy].sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1))
observer?.observe(copy)
}
function unregister(copy: HTMLElement) {
steps.value = steps.value.filter((step) => step !== copy)
observer?.unobserve(copy)
}
provideStickyScroll({ steps, active, progress, register, unregister })
// Each step's progress, from 0 to 1. A step longer than a screen counts the stretch where its copy stays put: 0
// as it settles, 1 just as it lets go, so whatever the media draws is finished while it is still in view. Any
// other step counts from its copy's top reaching the middle of the window to its bottom leaving it. Measured at
// most once a frame, however fast the scroll events come.
const clamp = (value: number) => Math.min(1, Math.max(0, value))
function measure() {
frame = 0
const middle = window.innerHeight / 2
progress.value = steps.value.map((step) => {
const box = step.getBoundingClientRect()
const pinned = step.firstElementChild as HTMLElement | null
const travel = pinned ? box.height - pinned.offsetHeight : 0
if (pinned && travel > 1) return clamp((pinned.getBoundingClientRect().top - box.top) / travel)
return box.height > 0 ? clamp((middle - box.top) / box.height) : 0
})
}
function schedule() {
if (!frame) frame = requestAnimationFrame(measure)
}
onMounted(() => {
// The step crossing the middle of the window is the one shown. Above the first step or past the last, the
// nearest one stays.
observer = new IntersectionObserver(
(entries) => {
const crossing = entries.find((entry) => entry.isIntersecting)
const index = crossing ? steps.value.indexOf(crossing.target as HTMLElement) : -1
if (index !== -1) active.value = index
},
{ rootMargin: '-50% 0px -50% 0px' },
)
for (const step of steps.value) observer.observe(step)
ready.value = true
measure()
window.addEventListener('scroll', schedule, { passive: true })
window.addEventListener('resize', schedule, { passive: true })
})
onBeforeUnmount(() => {
observer?.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener('scroll', schedule)
window.removeEventListener('resize', schedule)
})
// A step removed from the end takes the index with it.
watch(steps, (list) => {
if (list.length > 0 && active.value > list.length - 1) active.value = list.length - 1
if (ready.value) schedule()
})
const length = (value: number | string) => (typeof value === 'number' ? `${value}px` : value)
</script>
<template>
<div class="er-sticky-scroll" :data-side="side" :data-ready="ready ? '' : undefined" :style="{ '--er-sticky-scroll-top': length(props.top) }">
<div class="er-sticky-scroll-grid">
<slot />
</div>
</div>
</template>
<style>
@layer components {
.er-sticky-scroll {
/* The media fills the window below the offset, and each step's copy takes as long to scroll past. */
--er-sticky-scroll-height: calc(100dvh - var(--er-sticky-scroll-top, 0px));
container-type: inline-size;
width: 100%;
}
/*
* Every step's media sits in the same cell of the first row, stacked, and sticks for the whole section: a sticky
* element is held inside its containing block, which here is the grid, not its own row. The copy runs down the
* other column, one row per step. Each step keeps its media and copy together, so they read in order.
*/
.er-sticky-scroll-grid {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: clamp(1.5rem, 5cqi, 4rem);
}
}
</style>StickyScrollItem.vue 167 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useStickyScroll } from './context'
const props = withDefaults(
defineProps<{
/** A picture for the sticky column. */
image?: string
/** What the picture shows. Leave it empty only if the copy says the same thing. */
alt?: string
/** Large text for the sticky column, used when there is no picture. */
heading?: string
/** The heading's tag: the next level down from the section's own heading. */
headingAs?: string
/**
* How many screens of scrolling the step takes. Past 1, its copy stays put beside the media too, while
* `progress` runs from 0 to 1: room for something in the media, such as a chart, to draw itself.
*/
span?: number
}>(),
{ alt: '', headingAs: 'h3', span: 1 },
)
defineSlots<{
/**
* Anything else for the sticky column, in place of the picture or heading. `progress` is how far the step's
* copy has scrolled past the middle of the window, 0 to 1; `active` is whether this step is the one showing.
*/
media?: (props: { progress: number; active: boolean }) => unknown
/** The copy that scrolls past. */
default?: () => unknown
}>()
const scroll = useStickyScroll()
const copy = ref<HTMLElement>()
const index = computed(() => (copy.value ? scroll.steps.value.indexOf(copy.value) : -1))
const isActive = computed(() => index.value !== -1 && index.value === scroll.active.value)
const progress = computed(() => scroll.progress.value[index.value] ?? 0)
onMounted(() => scroll.register(copy.value!))
onBeforeUnmount(() => scroll.unregister(copy.value!))
// Tabbing to a link in a step shows that step's media, even before the scroll gets there.
function show() {
if (index.value !== -1) scroll.active.value = index.value
}
</script>
<template>
<div class="er-sticky-scroll-item" :data-state="isActive ? 'active' : 'inactive'" @focusin="show">
<div class="er-sticky-scroll-media">
<slot name="media" :progress="progress" :active="isActive">
<img v-if="image" :src="image" :alt="alt" loading="lazy" decoding="async" />
<component :is="headingAs" v-else-if="heading" class="er-sticky-scroll-heading">{{ heading }}</component>
</slot>
</div>
<div ref="copy" class="er-sticky-scroll-copy" :style="span === 1 ? undefined : { '--er-sticky-scroll-span': span }">
<div class="er-sticky-scroll-copy-inner">
<slot />
</div>
</div>
</div>
</template>
<style>
@layer components {
/* No box of its own: its media and copy are placed straight into the section's grid. */
.er-sticky-scroll-item {
display: contents;
}
.er-sticky-scroll-media {
position: sticky;
top: var(--er-sticky-scroll-top, 0px);
grid-row: 1;
grid-column: 1;
align-self: start;
display: grid;
place-items: center;
height: var(--er-sticky-scroll-height);
opacity: 0;
pointer-events: none;
transition: opacity var(--er-duration) var(--er-ease);
}
/* As tall as the scrolling the step takes; its text sits in the middle of the window for all of it. */
.er-sticky-scroll-copy {
grid-column: 2;
min-height: calc(var(--er-sticky-scroll-height) * var(--er-sticky-scroll-span, 1));
max-width: 34rem;
font-size: 1.125rem;
line-height: 1.6;
}
.er-sticky-scroll-copy-inner {
position: sticky;
top: var(--er-sticky-scroll-top, 0px);
display: flex;
flex-direction: column;
justify-content: center;
min-height: var(--er-sticky-scroll-height);
}
.er-sticky-scroll[data-side='end'] .er-sticky-scroll-media {
grid-column: 2;
}
.er-sticky-scroll[data-side='end'] .er-sticky-scroll-copy {
grid-column: 1;
}
/* Before the page has run its script, the first step's media shows. */
.er-sticky-scroll-item[data-state='active'] > .er-sticky-scroll-media,
.er-sticky-scroll:not([data-ready]) .er-sticky-scroll-item:first-child > .er-sticky-scroll-media {
opacity: 1;
pointer-events: auto;
}
.er-sticky-scroll-media > img {
width: 100%;
max-height: calc(100% - 3rem);
aspect-ratio: 4 / 3;
border-radius: var(--er-radius-xl);
object-fit: cover;
}
.er-sticky-scroll-heading {
margin: 0;
font-size: clamp(2rem, 7cqi, 4.5rem);
font-weight: var(--er-weight-semibold);
line-height: 1.05;
letter-spacing: -0.03em;
text-wrap: balance;
}
/* A narrow space: one column, each step's media above its copy, nothing sticks. */
@container (max-width: 40rem) {
.er-sticky-scroll-grid {
grid-template-columns: 1fr;
}
.er-sticky-scroll .er-sticky-scroll-media,
.er-sticky-scroll .er-sticky-scroll-copy {
grid-row: auto;
grid-column: 1;
}
.er-sticky-scroll .er-sticky-scroll-media {
position: static;
place-items: start;
height: auto;
margin-top: 2.5rem;
opacity: 1;
pointer-events: auto;
}
.er-sticky-scroll-media > img {
max-height: none;
}
.er-sticky-scroll-copy {
padding-top: 1rem;
}
.er-sticky-scroll-copy,
.er-sticky-scroll-copy-inner {
position: static;
min-height: 0;
}
}
}
</style>context.ts 16 lines
import type { Ref, ShallowRef } from 'vue'
import { createContext } from '../../lib/context'
export interface StickyScrollContext {
/** Each step's copy, in page order. */
steps: Readonly<ShallowRef<HTMLElement[]>>
/** Index of the step whose media is showing. */
active: Ref<number>
/** How far each step's copy has scrolled past the middle of the window, 0 to 1, by index. */
progress: Readonly<Ref<number[]>>
register: (copy: HTMLElement) => void
unregister: (copy: HTMLElement) => void
}
export const [provideStickyScroll, useStickyScroll] = createContext<StickyScrollContext>('StickyScroll')index.ts 4 lines
/** Sticky scroll — two columns: one stays put and shows a picture or a large heading, and changes as each step's copy in the other column scrolls past. One column on narrow screens. */
export { default as StickyScroll } from './StickyScroll.vue'
export { default as StickyScrollItem } from './StickyScrollItem.vue'