@kolkrabbi/kol-component 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +2 -3
- package/src/atoms/RotaryDial.jsx +35 -21
- package/src/index.js +6 -18
- package/src/molecules/ButtonGroup.jsx +45 -0
- package/src/molecules/ColorInputRow.jsx +146 -126
- package/src/molecules/Slider.jsx +29 -14
- package/src/molecules/SplitToolButton.jsx +133 -0
- package/src/organisms/ContentFilters.jsx +2 -2
- package/src/organisms/GalleryCarousel.jsx +2 -1
- package/src/organisms/LoaderOverlay.jsx +11 -14
- package/src/organisms/MediaTileGallery.jsx +57 -0
- package/src/organisms/MediaViewer.jsx +89 -64
- package/src/atoms/PriceDisplay.jsx +0 -34
- package/src/atoms/TextPressure.jsx +0 -331
- package/src/atoms/TypeSample.jsx +0 -50
- package/src/atoms/TypeSpecCard.jsx +0 -42
- package/src/molecules/ArticleCard.jsx +0 -178
- package/src/molecules/WorkListItem.jsx +0 -83
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
- package/src/organisms/ArticleHeader.jsx +0 -95
- package/src/organisms/ColorLoader.jsx +0 -155
- package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
- package/src/organisms/ParallaxShelf.jsx +0 -141
- package/src/organisms/PortableTextRenderer.jsx +0 -115
- package/src/organisms/ProductDetailLayout.jsx +0 -189
- package/src/organisms/ScrollDriftGallery.jsx +0 -214
- package/src/organisms/StackHero.jsx +0 -83
- package/src/organisms/WorkCard.jsx +0 -120
- package/src/organisms/WorkViewToggle.jsx +0 -170
- package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
- package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
- package/src/organisms/foundry/TypefaceHero.jsx +0 -107
- package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
- package/src/organisms/foundry/VariableFontSection.jsx +0 -158
- package/src/organisms/foundry/glyphData.js +0 -30
- package/src/organisms/foundry/index.js +0 -21
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
-
import Dropdown from '../Dropdown.jsx'
|
|
3
|
-
import Divider from '../../atoms/Divider.jsx'
|
|
4
|
-
|
|
5
|
-
/* taxonomy-ok: nests DS Dropdown + Divider (relative imports) plus kol-icons's
|
|
6
|
-
* Icon (a package import the relative-import check can't see). */
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* SpecimenSectionHeader — the shared header every type-specimen section mounts
|
|
10
|
-
* under. A title (with an optional inline icon) on the left, up to two
|
|
11
|
-
* controlled dropdowns (weight + style/axis) on the right, and a horizontal
|
|
12
|
-
* divider beneath. It is the connective tissue that makes the foundry sections
|
|
13
|
-
* read as one system, so it is built before the sections that consume it.
|
|
14
|
-
*
|
|
15
|
-
* Fully controlled and data-free: it holds no font data, only label/badge text,
|
|
16
|
-
* an icon name, and the two dropdowns' value/onChange pairs supplied by the
|
|
17
|
-
* parent section. Promoted from the monorepo's route-local `FoundrySection`;
|
|
18
|
-
* the `Foundry*` name was a route artifact, this name describes the role.
|
|
19
|
-
*
|
|
20
|
-
* Title source forks: `label` wins, else `badgeText`. `size` swaps the title
|
|
21
|
-
* type stop (`sm` → kol-mono-12, else kol-mono-16). Each dropdown is
|
|
22
|
-
* independently gated; the weight dropdown also requires a non-empty
|
|
23
|
-
* `weightOptions`.
|
|
24
|
-
*
|
|
25
|
-
* Text casing: title and option labels render verbatim as authored — no
|
|
26
|
-
* text-transform. Author "Roman" / "Italic" / "Character Set" in intended case.
|
|
27
|
-
*
|
|
28
|
-
* @param {Object} props
|
|
29
|
-
* @param {string} props.label - Title text (takes precedence over badgeText).
|
|
30
|
-
* @param {string} props.badgeText - Fallback title when label is unset.
|
|
31
|
-
* @param {string} props.icon - KOL Icon name shown after the title; omit to hide.
|
|
32
|
-
* @param {'sm'|'lg'} props.size - Title type stop (default 'lg').
|
|
33
|
-
* @param {string} props.selectedStyle - Controlled value of the style/axis dropdown.
|
|
34
|
-
* @param {Function} props.onStyleChange - Style dropdown change handler.
|
|
35
|
-
* @param {boolean} props.showDropdown - Render the style/axis dropdown (default true).
|
|
36
|
-
* @param {Array<{label,value}>} props.styleOptions - Style dropdown options (default Roman/Italic).
|
|
37
|
-
* @param {string} props.selectedWeight - Controlled value of the weight dropdown.
|
|
38
|
-
* @param {Function} props.onWeightChange - Weight dropdown change handler.
|
|
39
|
-
* @param {boolean} props.showWeightDropdown - Gate the weight dropdown (default true; also needs options).
|
|
40
|
-
* @param {Array<{label,value}>} props.weightOptions - Weight dropdown options (default []).
|
|
41
|
-
*/
|
|
42
|
-
const SpecimenSectionHeader = ({
|
|
43
|
-
label,
|
|
44
|
-
badgeText,
|
|
45
|
-
icon,
|
|
46
|
-
size = 'lg',
|
|
47
|
-
selectedStyle,
|
|
48
|
-
onStyleChange,
|
|
49
|
-
showDropdown = true,
|
|
50
|
-
styleOptions = [
|
|
51
|
-
{ label: 'Roman', value: 'roman' },
|
|
52
|
-
{ label: 'Italic', value: 'italic' },
|
|
53
|
-
],
|
|
54
|
-
selectedWeight,
|
|
55
|
-
onWeightChange,
|
|
56
|
-
showWeightDropdown = true,
|
|
57
|
-
weightOptions = [],
|
|
58
|
-
}) => {
|
|
59
|
-
const title = label || badgeText
|
|
60
|
-
// kol-mono-text-lg / kol-mono-sm-regular don't exist in the DS theme; the
|
|
61
|
-
// nearest mono stops are kol-mono-16 (lg) and kol-mono-12 (sm).
|
|
62
|
-
const titleClass = size === 'sm' ? 'kol-mono-12' : 'kol-mono-16'
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<div className="flex flex-col gap-[13px]">
|
|
66
|
-
<div className="w-full flex flex-row justify-between items-end gap-4">
|
|
67
|
-
{/* Left: title + optional icon */}
|
|
68
|
-
<div className="flex items-center gap-3 md:gap-4">
|
|
69
|
-
<span className={titleClass}>{title}</span>
|
|
70
|
-
{icon && <Icon name={icon} size={20} />}
|
|
71
|
-
</div>
|
|
72
|
-
|
|
73
|
-
{/* Right: controlled dropdowns */}
|
|
74
|
-
<div className="flex items-center gap-4">
|
|
75
|
-
{showWeightDropdown && weightOptions.length > 0 && (
|
|
76
|
-
<Dropdown options={weightOptions} value={selectedWeight} onChange={onWeightChange} />
|
|
77
|
-
)}
|
|
78
|
-
{showDropdown && (
|
|
79
|
-
<Dropdown options={styleOptions} value={selectedStyle} onChange={onStyleChange} />
|
|
80
|
-
)}
|
|
81
|
-
</div>
|
|
82
|
-
</div>
|
|
83
|
-
|
|
84
|
-
<Divider variant="horizontal" />
|
|
85
|
-
</div>
|
|
86
|
-
)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export default SpecimenSectionHeader
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import Pill from '../atoms/Pill.jsx'
|
|
2
|
-
import Avatar from '../atoms/Avatar.jsx'
|
|
3
|
-
import Image from '../molecules/Image.jsx'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* ArticleHeader — long-form article masthead: a row of tag Pills, a display
|
|
7
|
-
* title, an author cluster (DS Avatar + name + role), a `date • readingTime`
|
|
8
|
-
* meta line, an optional excerpt, and an optional hero image. Pure
|
|
9
|
-
* presentational composition of a flat prop bag.
|
|
10
|
-
*
|
|
11
|
-
* De-Sanitized: the four embedded Sanity image-URL builders and the
|
|
12
|
-
* `resolveImageUrl` unpacker are gone — `heroImage` is a plain resolved src
|
|
13
|
-
* string handed straight to DS Image (which owns its own missing-asset
|
|
14
|
-
* fallback). The author avatar is the DS Avatar atom (initials), not a
|
|
15
|
-
* hand-rolled `<img>` — so `authorInitial` replaces the Sanity `authorImage`.
|
|
16
|
-
* The app `reveal` entrance utility is dropped.
|
|
17
|
-
*
|
|
18
|
-
* Name and meta render uppercase via a presentational class only — strings are
|
|
19
|
-
* authored as-is at the call site, no JS casing.
|
|
20
|
-
*
|
|
21
|
-
* @param {string[]} tags Pill labels; row hidden when empty
|
|
22
|
-
* @param {string} title display title
|
|
23
|
-
* @param {string} authorName author name (rendered uppercase via class)
|
|
24
|
-
* @param {string} authorTitle author role/title line
|
|
25
|
-
* @param {string} authorInitial avatar glyph; defaults to authorName's first char
|
|
26
|
-
* @param {string} date publication date text
|
|
27
|
-
* @param {string} readingTime reading-time text (prefixed `• ` in the meta line)
|
|
28
|
-
* @param {string} excerpt lede paragraph; block hidden when falsy
|
|
29
|
-
* @param {string} heroImage resolved hero image src (omit → no hero block)
|
|
30
|
-
* @param {string} className extra classes on the <header>
|
|
31
|
-
*/
|
|
32
|
-
export default function ArticleHeader({
|
|
33
|
-
tags = [],
|
|
34
|
-
title,
|
|
35
|
-
authorName,
|
|
36
|
-
authorTitle,
|
|
37
|
-
authorInitial,
|
|
38
|
-
date,
|
|
39
|
-
readingTime,
|
|
40
|
-
excerpt,
|
|
41
|
-
heroImage,
|
|
42
|
-
className = '',
|
|
43
|
-
}) {
|
|
44
|
-
const initial = authorInitial ?? (authorName ? authorName.trim().charAt(0) : '')
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<header className={`pb-12 ${className}`.trim()}>
|
|
48
|
-
<div className="max-w-[1400px] mx-auto flex flex-col">
|
|
49
|
-
{tags.length > 0 && (
|
|
50
|
-
<div className="flex flex-wrap items-center gap-2 pb-6">
|
|
51
|
-
{tags.map((tag, i) => (
|
|
52
|
-
<Pill key={i} variant="inverse" size="md">{tag}</Pill>
|
|
53
|
-
))}
|
|
54
|
-
</div>
|
|
55
|
-
)}
|
|
56
|
-
|
|
57
|
-
<div className="flex flex-col gap-6">
|
|
58
|
-
<h1 className="kol-prose-title text-balance">{title}</h1>
|
|
59
|
-
|
|
60
|
-
<div className="flex flex-wrap items-center gap-6 text-fg-64 pb-3">
|
|
61
|
-
<div className="flex items-center gap-4">
|
|
62
|
-
<Avatar initial={initial} size="lg" />
|
|
63
|
-
<div className="flex flex-col">
|
|
64
|
-
<span className="kol-helper-12 uppercase text-fg-48">{authorName}</span>
|
|
65
|
-
<span className="kol-mono-12 text-fg-64">{authorTitle}</span>
|
|
66
|
-
</div>
|
|
67
|
-
</div>
|
|
68
|
-
<div className="flex items-center gap-3 kol-helper-12 uppercase text-fg-48">
|
|
69
|
-
<span>{date}</span>
|
|
70
|
-
{readingTime && <span>• {readingTime}</span>}
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
73
|
-
|
|
74
|
-
{excerpt && (
|
|
75
|
-
<div className="pb-4 w-full lg:w-[80%]">
|
|
76
|
-
<p className="kol-mono-14 text-fg-64">{excerpt}</p>
|
|
77
|
-
</div>
|
|
78
|
-
)}
|
|
79
|
-
|
|
80
|
-
{heroImage && (
|
|
81
|
-
<div className="rounded overflow-hidden border border-fg-08">
|
|
82
|
-
<Image
|
|
83
|
-
src={heroImage}
|
|
84
|
-
alt=""
|
|
85
|
-
loading="eager"
|
|
86
|
-
className="object-cover"
|
|
87
|
-
style={{ width: '100%', aspectRatio: '4/2', objectFit: 'cover' }}
|
|
88
|
-
/>
|
|
89
|
-
</div>
|
|
90
|
-
)}
|
|
91
|
-
</div>
|
|
92
|
-
</div>
|
|
93
|
-
</header>
|
|
94
|
-
)
|
|
95
|
-
}
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { motion as Motion, useAnimationControls } from 'framer-motion'
|
|
2
|
-
import { useEffect, useRef, useState } from 'react'
|
|
3
|
-
import TextPressure from '../atoms/TextPressure.jsx'
|
|
4
|
-
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* ColorLoader — a full-height branded intro/loading curtain. Fills its parent
|
|
8
|
-
* on a solid backdrop, times in a variable-font wordmark (a live TextPressure
|
|
9
|
-
* effect) after `wordmarkDelay`, then reveals a bouncing down-chevron cue after
|
|
10
|
-
* `scrollDelay`. With `dismissOnClick`, clicking the curtain slides the whole
|
|
11
|
-
* thing up out of view and fires `onComplete`; otherwise the parent owns
|
|
12
|
-
* dismissal (unmount when its work is done). Generalized from the app's
|
|
13
|
-
* KOLKRABBI loader: the cursor trail, `cursor: none`, and window-scroll/keyboard
|
|
14
|
-
* gesture hijack are all dropped — exit is a plain click or parent-driven.
|
|
15
|
-
*
|
|
16
|
-
* Reduced motion → no timers, no tweens: one static frame (wordmark + cue shown
|
|
17
|
-
* at rest) and `onComplete` fires immediately on mount, so the curtain never
|
|
18
|
-
* makes a reduced-motion user wait.
|
|
19
|
-
*
|
|
20
|
-
* The wordmark needs a variable font — see TextPressure's font contract. Pass
|
|
21
|
-
* `fontFamily` / `fontUrl` to point at the KOL variable font (a consumer asset).
|
|
22
|
-
*
|
|
23
|
-
* @param {string} text wordmark string fed to TextPressure
|
|
24
|
-
* @param {string} fontFamily wordmark font family (→ TextPressure)
|
|
25
|
-
* @param {string} [fontUrl] variable-font URL → TextPressure @font-face
|
|
26
|
-
* @param {() => void} onComplete fired after the exit slide-up (or at once, reduced)
|
|
27
|
-
* @param {boolean} dismissOnClick click the curtain to slide up + complete
|
|
28
|
-
* @param {string} bgColor backdrop color (KOL token / CSS color)
|
|
29
|
-
* @param {string} markColor wordmark + chevron color (KOL token / CSS color)
|
|
30
|
-
* @param {number} wordmarkDelay ms before the wordmark fades in
|
|
31
|
-
* @param {number} scrollDelay ms before the chevron cue reveals
|
|
32
|
-
* @param {number} exitDuration seconds for the slide-up exit tween
|
|
33
|
-
*/
|
|
34
|
-
export default function ColorLoader({
|
|
35
|
-
text = 'KOLKRABBI',
|
|
36
|
-
fontFamily = 'var(--kol-font-family-sans)',
|
|
37
|
-
fontUrl,
|
|
38
|
-
onComplete,
|
|
39
|
-
dismissOnClick = false,
|
|
40
|
-
bgColor = 'var(--kol-surface-primary)',
|
|
41
|
-
markColor = 'var(--kol-fg-emphasis)',
|
|
42
|
-
wordmarkDelay = 1000,
|
|
43
|
-
scrollDelay = 2000,
|
|
44
|
-
exitDuration = 1.8,
|
|
45
|
-
}) {
|
|
46
|
-
const reducedMotion = usePrefersReducedMotion()
|
|
47
|
-
const [showMark, setShowMark] = useState(reducedMotion)
|
|
48
|
-
const [showCue, setShowCue] = useState(reducedMotion)
|
|
49
|
-
const [exiting, setExiting] = useState(false)
|
|
50
|
-
const controls = useAnimationControls()
|
|
51
|
-
const firedRef = useRef(false)
|
|
52
|
-
|
|
53
|
-
const fireComplete = () => {
|
|
54
|
-
if (firedRef.current) return
|
|
55
|
-
firedRef.current = true
|
|
56
|
-
onComplete?.()
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (reducedMotion) {
|
|
61
|
-
// Skip the whole sequence: static frame, signal done at once.
|
|
62
|
-
setShowMark(true)
|
|
63
|
-
setShowCue(true)
|
|
64
|
-
fireComplete()
|
|
65
|
-
return undefined
|
|
66
|
-
}
|
|
67
|
-
const markTimer = setTimeout(() => setShowMark(true), wordmarkDelay)
|
|
68
|
-
const cueTimer = setTimeout(() => setShowCue(true), scrollDelay)
|
|
69
|
-
return () => {
|
|
70
|
-
clearTimeout(markTimer)
|
|
71
|
-
clearTimeout(cueTimer)
|
|
72
|
-
}
|
|
73
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
-
}, [reducedMotion, wordmarkDelay, scrollDelay])
|
|
75
|
-
|
|
76
|
-
const dismiss = () => {
|
|
77
|
-
if (exiting) return
|
|
78
|
-
setExiting(true)
|
|
79
|
-
if (reducedMotion) {
|
|
80
|
-
fireComplete()
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
controls
|
|
84
|
-
.start({ y: '-100%', transition: { duration: exitDuration, ease: 'easeInOut' } })
|
|
85
|
-
.then(fireComplete)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return (
|
|
89
|
-
<Motion.div
|
|
90
|
-
className={`flex h-full w-full flex-col ${dismissOnClick ? 'cursor-pointer' : ''}`}
|
|
91
|
-
style={{ backgroundColor: bgColor }}
|
|
92
|
-
animate={controls}
|
|
93
|
-
initial={{ y: 0 }}
|
|
94
|
-
onClick={dismissOnClick ? dismiss : undefined}
|
|
95
|
-
>
|
|
96
|
-
{/* Top third — spacer */}
|
|
97
|
-
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
98
|
-
|
|
99
|
-
{/* Middle third — wordmark + cue */}
|
|
100
|
-
<div className="flex flex-1 flex-col items-center justify-center gap-12 self-stretch">
|
|
101
|
-
<Motion.div
|
|
102
|
-
initial={{ opacity: 0 }}
|
|
103
|
-
animate={{ opacity: showMark ? 1 : 0 }}
|
|
104
|
-
transition={{ duration: reducedMotion ? 0 : 0.5 }}
|
|
105
|
-
className="w-full px-6"
|
|
106
|
-
>
|
|
107
|
-
<div className="mx-auto h-20 w-full max-w-[280px] sm:h-24 sm:max-w-[400px] md:h-32 md:max-w-[600px]">
|
|
108
|
-
<TextPressure
|
|
109
|
-
text={text}
|
|
110
|
-
fontFamily={fontFamily}
|
|
111
|
-
fontUrl={fontUrl}
|
|
112
|
-
textColor={markColor}
|
|
113
|
-
flex
|
|
114
|
-
width
|
|
115
|
-
weight
|
|
116
|
-
italic={false}
|
|
117
|
-
minFontSize={40}
|
|
118
|
-
/>
|
|
119
|
-
</div>
|
|
120
|
-
</Motion.div>
|
|
121
|
-
|
|
122
|
-
<Motion.div
|
|
123
|
-
initial={{ opacity: 0, y: 0 }}
|
|
124
|
-
animate={{
|
|
125
|
-
opacity: showCue ? 0.6 : 0,
|
|
126
|
-
y: showCue && !reducedMotion ? [0, 12, 0] : 0,
|
|
127
|
-
}}
|
|
128
|
-
transition={{
|
|
129
|
-
opacity: { duration: reducedMotion ? 0 : 0.5 },
|
|
130
|
-
y: reducedMotion ? { duration: 0 } : { duration: 1.5, repeat: Infinity, ease: 'easeInOut' },
|
|
131
|
-
}}
|
|
132
|
-
className="mt-12"
|
|
133
|
-
style={{ color: markColor }}
|
|
134
|
-
>
|
|
135
|
-
<svg
|
|
136
|
-
width="32"
|
|
137
|
-
height="32"
|
|
138
|
-
viewBox="0 0 24 24"
|
|
139
|
-
fill="none"
|
|
140
|
-
stroke="currentColor"
|
|
141
|
-
strokeWidth="2"
|
|
142
|
-
strokeLinecap="round"
|
|
143
|
-
strokeLinejoin="round"
|
|
144
|
-
aria-hidden="true"
|
|
145
|
-
>
|
|
146
|
-
<polyline points="6 9 12 15 18 9" />
|
|
147
|
-
</svg>
|
|
148
|
-
</Motion.div>
|
|
149
|
-
</div>
|
|
150
|
-
|
|
151
|
-
{/* Bottom third — spacer */}
|
|
152
|
-
<div className="flex flex-1 items-end justify-center self-stretch" />
|
|
153
|
-
</Motion.div>
|
|
154
|
-
)
|
|
155
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef } from 'react'
|
|
2
|
-
import { gsap } from 'gsap'
|
|
3
|
-
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* DiagonalMarqueeRiver — an auto-scrolling multi-column marquee "river":
|
|
7
|
-
* `items` are dealt round-robin into `colCount` vertical columns, each column
|
|
8
|
-
* runs an infinite constant-velocity GSAP y-tween at its own speed, and the
|
|
9
|
-
* whole block is rotated + scaled so the columns stream diagonally across a
|
|
10
|
-
* tall stage. An IntersectionObserver pauses every tween while the stage is
|
|
11
|
-
* off-screen. Data-agnostic — the card is a `renderItem` slot; the engine only
|
|
12
|
-
* measures scrollHeight and tweens y.
|
|
13
|
-
*
|
|
14
|
-
* Each column's list is rendered twice so the tween can travel exactly one set
|
|
15
|
-
* then repeat with an invisible wrap. On `prefers-reduced-motion` no GSAP runs
|
|
16
|
-
* and the columns render static. The tween set is rebuilt on container resize
|
|
17
|
-
* (a ResizeObserver remeasures scrollHeight — the source keyed only on the item
|
|
18
|
-
* list and drifted when the viewport changed).
|
|
19
|
-
*
|
|
20
|
-
* @param {any[]} items pool dealt round-robin into columns
|
|
21
|
-
* @param {(item:any,i:number)=>ReactNode} renderItem card slot per item
|
|
22
|
-
* @param {number} colCount number of columns (default 4)
|
|
23
|
-
* @param {number[]} columnSpeeds px/s per column (default [28,38,22,34], fallback 30)
|
|
24
|
-
* @param {number} gap vertical gap between cards and gap-between-columns, px (default 24)
|
|
25
|
-
* @param {string} height stage height (default '300vh')
|
|
26
|
-
* @param {number} rotate tilt of the block, deg (default -15)
|
|
27
|
-
* @param {number} scale oversize of the block (default 1.3)
|
|
28
|
-
* @param {boolean} shuffle one-time shuffle of `items` before dealing (default false)
|
|
29
|
-
*/
|
|
30
|
-
export default function DiagonalMarqueeRiver({
|
|
31
|
-
items = [],
|
|
32
|
-
renderItem,
|
|
33
|
-
colCount = 4,
|
|
34
|
-
columnSpeeds = [28, 38, 22, 34],
|
|
35
|
-
gap = 24,
|
|
36
|
-
height = '300vh',
|
|
37
|
-
rotate = -15,
|
|
38
|
-
scale = 1.3,
|
|
39
|
-
shuffle = false,
|
|
40
|
-
}) {
|
|
41
|
-
const containerRef = useRef(null)
|
|
42
|
-
const columnRefs = useRef([])
|
|
43
|
-
const tweensRef = useRef([])
|
|
44
|
-
const reducedMotion = usePrefersReducedMotion()
|
|
45
|
-
|
|
46
|
-
// Deal items round-robin into columns (optionally shuffled once).
|
|
47
|
-
const columns = useMemo(() => {
|
|
48
|
-
const pool = shuffle ? [...items].sort(() => Math.random() - 0.5) : items
|
|
49
|
-
const cols = Array.from({ length: colCount }, () => [])
|
|
50
|
-
pool.forEach((item, i) => cols[i % colCount].push(item))
|
|
51
|
-
return cols
|
|
52
|
-
}, [items, colCount, shuffle])
|
|
53
|
-
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (reducedMotion) return undefined
|
|
56
|
-
|
|
57
|
-
const build = () => {
|
|
58
|
-
const colEls = columnRefs.current.filter(Boolean)
|
|
59
|
-
tweensRef.current.forEach((tw) => tw.kill())
|
|
60
|
-
tweensRef.current = []
|
|
61
|
-
if (!colEls.length) return
|
|
62
|
-
|
|
63
|
-
colEls.forEach((colEl, i) => {
|
|
64
|
-
colEl.style.willChange = 'transform'
|
|
65
|
-
const singleSetHeight = colEl.scrollHeight / 2 // one copy of the doubled list
|
|
66
|
-
const speed = columnSpeeds[i] || 30
|
|
67
|
-
const initialOffset = -(singleSetHeight * ((i * 0.15) % 1)) // staggered per-column start
|
|
68
|
-
gsap.set(colEl, { y: initialOffset })
|
|
69
|
-
const tween = gsap.to(colEl, {
|
|
70
|
-
y: initialOffset - singleSetHeight, // travel exactly one set → seamless
|
|
71
|
-
duration: singleSetHeight / speed, // constant px/s regardless of column length
|
|
72
|
-
ease: 'none',
|
|
73
|
-
repeat: -1,
|
|
74
|
-
})
|
|
75
|
-
tweensRef.current.push(tween)
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
build()
|
|
80
|
-
|
|
81
|
-
// Pause tweens while the stage is off-screen.
|
|
82
|
-
const io = new IntersectionObserver(
|
|
83
|
-
([entry]) => {
|
|
84
|
-
tweensRef.current.forEach((tw) => (entry.isIntersecting ? tw.resume() : tw.pause()))
|
|
85
|
-
},
|
|
86
|
-
{ threshold: 0 },
|
|
87
|
-
)
|
|
88
|
-
if (containerRef.current) io.observe(containerRef.current)
|
|
89
|
-
|
|
90
|
-
// [source-bug] resize-remeasure: source keyed only on the item list, so a
|
|
91
|
-
// viewport change left the tweens measuring a stale scrollHeight. Rebuild
|
|
92
|
-
// on container resize (debounced).
|
|
93
|
-
let raf = 0
|
|
94
|
-
const ro = new ResizeObserver(() => {
|
|
95
|
-
cancelAnimationFrame(raf)
|
|
96
|
-
raf = requestAnimationFrame(build)
|
|
97
|
-
})
|
|
98
|
-
if (containerRef.current) ro.observe(containerRef.current)
|
|
99
|
-
|
|
100
|
-
return () => {
|
|
101
|
-
io.disconnect()
|
|
102
|
-
ro.disconnect()
|
|
103
|
-
cancelAnimationFrame(raf)
|
|
104
|
-
tweensRef.current.forEach((tw) => tw.kill())
|
|
105
|
-
tweensRef.current = []
|
|
106
|
-
}
|
|
107
|
-
}, [columns, columnSpeeds, reducedMotion])
|
|
108
|
-
|
|
109
|
-
return (
|
|
110
|
-
<div ref={containerRef} className="w-full overflow-hidden" style={{ height }}>
|
|
111
|
-
<div
|
|
112
|
-
className="flex gap-6"
|
|
113
|
-
style={{
|
|
114
|
-
transform: `rotate(${rotate}deg) scale(${scale})`,
|
|
115
|
-
transformOrigin: 'center center',
|
|
116
|
-
height: '140%',
|
|
117
|
-
marginTop: '-20%',
|
|
118
|
-
}}
|
|
119
|
-
>
|
|
120
|
-
{columns.map((col, colIndex) => (
|
|
121
|
-
<div key={colIndex} className="flex-1 overflow-hidden">
|
|
122
|
-
<div
|
|
123
|
-
ref={(el) => {
|
|
124
|
-
columnRefs.current[colIndex] = el
|
|
125
|
-
}}
|
|
126
|
-
className="flex flex-col"
|
|
127
|
-
style={{ gap: `${gap}px` }}
|
|
128
|
-
>
|
|
129
|
-
{[...col, ...col].map((item, i) => (
|
|
130
|
-
<div key={i}>{renderItem?.(item, i)}</div>
|
|
131
|
-
))}
|
|
132
|
-
</div>
|
|
133
|
-
</div>
|
|
134
|
-
))}
|
|
135
|
-
</div>
|
|
136
|
-
</div>
|
|
137
|
-
)
|
|
138
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
-
import useEmblaCarousel from 'embla-carousel-react'
|
|
3
|
-
import WorkCard from './WorkCard.jsx'
|
|
4
|
-
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
-
|
|
6
|
-
/* Scroll-driven parallax strength — fraction of scroll delta applied to the
|
|
7
|
-
* shelf. Eases in quadratically with page-scroll progress (min → max). */
|
|
8
|
-
const SCROLL_PARALLAX_MIN = 0.1
|
|
9
|
-
const SCROLL_PARALLAX_MAX = 0.4
|
|
10
|
-
const DRAG_THRESHOLD_SQ = 25
|
|
11
|
-
/* Aligned edge gets the big gutter so the first/last card lands under a
|
|
12
|
-
* 1400px content column. */
|
|
13
|
-
const EDGE_GUTTER = 'max(4rem, calc((100vw - 1400px) / 2 + 16rem))'
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* ParallaxShelf — one horizontal shelf row: a drag-free Embla carousel of
|
|
17
|
-
* project cards with scroll-driven parallax. As the page scrolls the track is
|
|
18
|
-
* nudged horizontally by `delta × parallax × direction`, the strength easing in
|
|
19
|
-
* the further down the page you are; alternating rows (`fromLeft`) scroll in
|
|
20
|
-
* opposite directions for a woven wall of shelves. Includes a drag-vs-click
|
|
21
|
-
* guard (a drag never fires a card link) and an edge-aligned category label.
|
|
22
|
-
*
|
|
23
|
-
* Calls `useEmblaCarousel` directly (FeaturedCarousel's precedent) because the
|
|
24
|
-
* parallax nudge needs `internalEngine()`, which DS Carousel encapsulates and
|
|
25
|
-
* must not be modified. The source's `WheelGesturesPlugin` is dropped (not a
|
|
26
|
-
* DS dependency). Parallax is disabled under prefers-reduced-motion and on
|
|
27
|
-
* small screens — both fall back to a plain wheel/drag carousel.
|
|
28
|
-
*
|
|
29
|
-
* Card-agnostic via `renderCard`; defaults to DS **WorkCard** with an
|
|
30
|
-
* `index`-staggered ragged height. Pass a plain `items` array of flat project
|
|
31
|
-
* objects (no Sanity keying).
|
|
32
|
-
*
|
|
33
|
-
* Text casing: the label renders verbatim (author `type.label` as e.g.
|
|
34
|
-
* "Client Work") — no text-transform.
|
|
35
|
-
*
|
|
36
|
-
* @param {{label:string}} type category; `label` renders in the edge caption
|
|
37
|
-
* @param {Array} items flat project objects passed to the card renderer
|
|
38
|
-
* @param {boolean} fromLeft direction of alignment / start index / parallax sign / label side (default false)
|
|
39
|
-
* @param {Function} renderCard (item, index) => ReactNode — card renderer (default WorkCard)
|
|
40
|
-
* @param {Function} onNavigate (href, event) => void — forwarded to the default WorkCard
|
|
41
|
-
* @param {string} className extra classes on the section
|
|
42
|
-
*/
|
|
43
|
-
export default function ParallaxShelf({
|
|
44
|
-
type,
|
|
45
|
-
items = [],
|
|
46
|
-
fromLeft = false,
|
|
47
|
-
renderCard,
|
|
48
|
-
onNavigate,
|
|
49
|
-
className = '',
|
|
50
|
-
}) {
|
|
51
|
-
const reduced = usePrefersReducedMotion()
|
|
52
|
-
const [isMobile] = useState(
|
|
53
|
-
() => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches,
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
const [emblaRef, emblaApi] = useEmblaCarousel({
|
|
57
|
-
dragFree: true,
|
|
58
|
-
align: fromLeft ? 'end' : 'start',
|
|
59
|
-
containScroll: 'trimSnaps',
|
|
60
|
-
...(fromLeft && { startIndex: items.length - 1 }),
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
const sectionRef = useRef(null)
|
|
64
|
-
const lastScrollY = useRef(0)
|
|
65
|
-
|
|
66
|
-
// Scroll-driven parallax: page scroll nudges the carousel while in view.
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (!emblaApi || reduced || isMobile) return
|
|
69
|
-
lastScrollY.current = window.scrollY
|
|
70
|
-
|
|
71
|
-
const onScroll = () => {
|
|
72
|
-
const section = sectionRef.current
|
|
73
|
-
if (!section) return
|
|
74
|
-
const rect = section.getBoundingClientRect()
|
|
75
|
-
const inView = rect.bottom > 0 && rect.top < window.innerHeight
|
|
76
|
-
if (!inView) {
|
|
77
|
-
lastScrollY.current = window.scrollY
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
const delta = window.scrollY - lastScrollY.current
|
|
81
|
-
lastScrollY.current = window.scrollY
|
|
82
|
-
const scrollProgress = Math.min(window.scrollY / (document.body.scrollHeight - window.innerHeight), 1)
|
|
83
|
-
const parallax = SCROLL_PARALLAX_MIN + (SCROLL_PARALLAX_MAX - SCROLL_PARALLAX_MIN) * (scrollProgress * scrollProgress)
|
|
84
|
-
const engine = emblaApi.internalEngine()
|
|
85
|
-
const offset = delta * parallax * (fromLeft ? 1 : -1)
|
|
86
|
-
engine.scrollBody.useDuration(0)
|
|
87
|
-
engine.scrollTo.distance(offset, false)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
window.addEventListener('scroll', onScroll, { passive: true })
|
|
91
|
-
return () => window.removeEventListener('scroll', onScroll)
|
|
92
|
-
}, [emblaApi, fromLeft, reduced, isMobile])
|
|
93
|
-
|
|
94
|
-
// Drag-vs-click guard (5px): a drag suppresses the ensuing card-link click.
|
|
95
|
-
const dragged = useRef(false)
|
|
96
|
-
const pointerStart = useRef({ x: 0, y: 0 })
|
|
97
|
-
const onPointerDown = useCallback((e) => {
|
|
98
|
-
dragged.current = false
|
|
99
|
-
pointerStart.current = { x: e.clientX, y: e.clientY }
|
|
100
|
-
}, [])
|
|
101
|
-
const onPointerMove = useCallback((e) => {
|
|
102
|
-
if (dragged.current) return
|
|
103
|
-
const dx = e.clientX - pointerStart.current.x
|
|
104
|
-
const dy = e.clientY - pointerStart.current.y
|
|
105
|
-
if (dx * dx + dy * dy > DRAG_THRESHOLD_SQ) dragged.current = true
|
|
106
|
-
}, [])
|
|
107
|
-
const onClickCapture = useCallback((e) => {
|
|
108
|
-
if (dragged.current) e.preventDefault()
|
|
109
|
-
}, [])
|
|
110
|
-
|
|
111
|
-
const card = (item, i) =>
|
|
112
|
-
renderCard ? renderCard(item, i) : <WorkCard key={i} {...item} index={i} onNavigate={onNavigate} />
|
|
113
|
-
|
|
114
|
-
return (
|
|
115
|
-
<section ref={sectionRef} className={`py-6 md:py-16 ${className}`.trim()}>
|
|
116
|
-
<div
|
|
117
|
-
className="overflow-visible select-none"
|
|
118
|
-
ref={emblaRef}
|
|
119
|
-
style={{
|
|
120
|
-
paddingLeft: fromLeft ? undefined : EDGE_GUTTER,
|
|
121
|
-
paddingRight: fromLeft ? EDGE_GUTTER : undefined,
|
|
122
|
-
}}
|
|
123
|
-
>
|
|
124
|
-
<div
|
|
125
|
-
className="flex gap-8 items-end"
|
|
126
|
-
onPointerDown={onPointerDown}
|
|
127
|
-
onPointerMove={onPointerMove}
|
|
128
|
-
onClickCapture={onClickCapture}
|
|
129
|
-
>
|
|
130
|
-
{items.map((item, i) => card(item, i))}
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
{type?.label && (
|
|
135
|
-
<div className={`max-w-[1400px] mx-auto mt-4 md:mt-6 ${fromLeft ? 'pr-4 md:pr-64 text-right' : 'pl-4 md:pl-64'}`}>
|
|
136
|
-
<p className="kol-helper-12 text-auto">{type.label}</p>
|
|
137
|
-
</div>
|
|
138
|
-
)}
|
|
139
|
-
</section>
|
|
140
|
-
)
|
|
141
|
-
}
|