@kolkrabbi/kol-foundry 0.1.0 → 0.3.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 +5 -1
- package/package.json +6 -5
- package/src/ColorLoader.jsx +155 -0
- package/src/GlyphMetricsSection.jsx +107 -0
- package/src/TextPressure.jsx +331 -0
- package/src/TypeSample.jsx +50 -0
- package/src/TypeSpecCard.jsx +42 -0
- package/src/TypefaceLibraryGrid.jsx +75 -0
- package/src/TypefaceLibraryGridWithVariables.jsx +236 -0
- package/src/TypefaceLibraryItem.jsx +190 -0
- package/src/TypefaceSpecimenPage.jsx +223 -0
- package/src/TypefaceVariablePreview.jsx +160 -0
- package/src/index.js +26 -9
- package/src/typefaceConfig.js +227 -0
package/README.md
CHANGED
|
@@ -13,6 +13,10 @@ The KOL **type-foundry system** — the type-specimen apparatus, lifted out of `
|
|
|
13
13
|
| `TypefaceStyleSection` | per-style specimen block |
|
|
14
14
|
| `FontPreviewSection` | size-ladder / preview control |
|
|
15
15
|
| `SpecimenSectionHeader` | shared section header |
|
|
16
|
+
| `TypeSample` | labeled live type-specimen block (props-driven) |
|
|
17
|
+
| `TypeSpecCard` | font-metric data sheet + live sample slot |
|
|
18
|
+
| `TextPressure` | variable-font glyphs deforming toward the pointer |
|
|
19
|
+
| `ColorLoader` | branded loading curtain with a live variable-font wordmark |
|
|
16
20
|
| `glyphSets`, `glyphCategories`, `SPECIMEN_SAMPLE_TEXT` | glyph data |
|
|
17
21
|
|
|
18
22
|
```js
|
|
@@ -21,4 +25,4 @@ import { TypefaceHero, VariableFontSection } from '@kolkrabbi/kol-foundry'
|
|
|
21
25
|
|
|
22
26
|
## Requirements
|
|
23
27
|
|
|
24
|
-
Components take flat prop bags — typeface metrics / font files are consumer-supplied. Shared primitives (`Tag`, `Pill`, `Slider`, `Button`, `Divider`, `Dropdown`, `useAxisAnimation`) stay in `@kolkrabbi/kol-component`; this package depends on them. Styling comes from `@kolkrabbi/kol-theme`. Vite + Tailwind v4 consumer (`@source "…/node_modules/@kolkrabbi/kol-foundry/src"`).
|
|
28
|
+
Components take flat prop bags — typeface metrics / font files are consumer-supplied. Shared primitives (`Tag`, `Pill`, `Slider`, `Button`, `Divider`, `Dropdown`, `useAxisAnimation`, `usePrefersReducedMotion`) stay in `@kolkrabbi/kol-component`; this package depends on them. `framer-motion` is a peer (`ColorLoader`). Styling comes from `@kolkrabbi/kol-theme`. Vite + Tailwind v4 consumer (`@source "…/node_modules/@kolkrabbi/kol-foundry/src"` — Tailwind skips `node_modules` when scanning, so without this line the specimen layouts never generate).
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "KOL
|
|
5
|
+
"description": "KOL foundry component set — the type-specimen apparatus: typeface hero, variable-font axis playground, parsed-metric glyph inspector, character-set browser, font-preview, and the typeface-catalog grid. Every component renders, inspects, or manipulates a live font. Typeface data is consumer-injected. Sits above @kolkrabbi/kol-{theme,icons,component}.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.js",
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
".": "./src/index.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@kolkrabbi/kol-component": "0.
|
|
15
|
-
"@kolkrabbi/kol-
|
|
16
|
-
"@kolkrabbi/kol-
|
|
14
|
+
"@kolkrabbi/kol-component": "0.8.0",
|
|
15
|
+
"@kolkrabbi/kol-theme": "0.7.3",
|
|
16
|
+
"@kolkrabbi/kol-icons": "0.5.0"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
|
20
20
|
"react-dom": "^18.3.0 || ^19.0.0",
|
|
21
|
+
"framer-motion": "^12.0.0",
|
|
21
22
|
"opentype.js": "^1.3.4"
|
|
22
23
|
},
|
|
23
24
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { motion as Motion, useAnimationControls } from 'framer-motion'
|
|
2
|
+
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { usePrefersReducedMotion } from '@kolkrabbi/kol-component'
|
|
4
|
+
import TextPressure from './TextPressure.jsx'
|
|
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
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import SpecimenSectionHeader from './SpecimenSectionHeader.jsx'
|
|
3
|
+
import GlyphMetricsGrid from './GlyphMetricsGrid.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GlyphMetricsSection — the "Glyph Metrics" specimen section: a section header
|
|
7
|
+
* (Roman/Italic or Weight/Width axis dropdowns) over the parsed-metric
|
|
8
|
+
* GlyphMetricsGrid. Owns the style/axis selection state and feeds the grid the
|
|
9
|
+
* chosen font URL + `variationSettings`.
|
|
10
|
+
*
|
|
11
|
+
* Composes the package's existing canonical leaves — SpecimenSectionHeader
|
|
12
|
+
* (promoted from the monorepo's `FoundrySection`) and GlyphMetricsGrid (which
|
|
13
|
+
* already handles font I/O with opentype.js as an optional peer).
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} props
|
|
16
|
+
* @param {string} props.fontFamily - CSS family fallback until the parsed face injects.
|
|
17
|
+
* @param {string} props.fontUrlRoman - Roman/upright font URL.
|
|
18
|
+
* @param {string} props.fontUrlItalic - Italic font URL.
|
|
19
|
+
* @param {'normal'|'italic'} props.fontStyle - Initial style variant.
|
|
20
|
+
* @param {string} props.badgeText - Section title text.
|
|
21
|
+
* @param {boolean} props.showDropdown - Show the Roman/Italic style dropdown.
|
|
22
|
+
* @param {boolean} props.hasWeight - Font has a weight axis.
|
|
23
|
+
* @param {boolean} props.hasWidth - Font has a width axis.
|
|
24
|
+
* @param {Array<{label,weight}>} props.weights - Weight axis options.
|
|
25
|
+
* @param {Array<{label,width}>} props.widths - Width axis options.
|
|
26
|
+
*/
|
|
27
|
+
const GlyphMetricsSection = ({
|
|
28
|
+
fontFamily = 'TGMalromur',
|
|
29
|
+
fontUrlRoman,
|
|
30
|
+
fontUrlItalic,
|
|
31
|
+
fontStyle = 'normal',
|
|
32
|
+
badgeText = 'Málrómur',
|
|
33
|
+
showDropdown = true,
|
|
34
|
+
// Variable font axis support
|
|
35
|
+
hasWeight = false,
|
|
36
|
+
hasWidth = false,
|
|
37
|
+
weights = [],
|
|
38
|
+
widths = []
|
|
39
|
+
}) => {
|
|
40
|
+
const [selectedStyleVariant, setSelectedStyleVariant] = useState(
|
|
41
|
+
fontStyle === 'italic' ? 'italic' : 'roman'
|
|
42
|
+
)
|
|
43
|
+
const [selectedAxis] = useState('weight')
|
|
44
|
+
const [selectedWeight, setSelectedWeight] = useState(400)
|
|
45
|
+
const [selectedWidth, setSelectedWidth] = useState(100)
|
|
46
|
+
|
|
47
|
+
const isItalic = selectedStyleVariant === 'italic'
|
|
48
|
+
|
|
49
|
+
// Select the correct font URL based on the dropdown
|
|
50
|
+
const currentFontUrl = isItalic ? fontUrlItalic : fontUrlRoman
|
|
51
|
+
|
|
52
|
+
// Build axis options for dropdown
|
|
53
|
+
const axisOptions = []
|
|
54
|
+
if (hasWeight) axisOptions.push({ label: 'Weight', value: 'weight' })
|
|
55
|
+
if (hasWidth) axisOptions.push({ label: 'Width', value: 'width' })
|
|
56
|
+
|
|
57
|
+
// Build value options based on selected axis
|
|
58
|
+
const valueOptions = selectedAxis === 'weight'
|
|
59
|
+
? weights.map(w => ({ label: w.label, value: w.weight }))
|
|
60
|
+
: widths.map(w => ({ label: w.label, value: w.width }))
|
|
61
|
+
|
|
62
|
+
const selectedValue = selectedAxis === 'weight' ? selectedWeight : selectedWidth
|
|
63
|
+
const onValueChange = selectedAxis === 'weight' ? setSelectedWeight : setSelectedWidth
|
|
64
|
+
|
|
65
|
+
// Show axis dropdown if font has variable axes
|
|
66
|
+
const showAxisDropdown = axisOptions.length > 0
|
|
67
|
+
|
|
68
|
+
// Get variation settings for the glyph display
|
|
69
|
+
const variationSettings = {}
|
|
70
|
+
if (hasWeight) variationSettings.wght = selectedWeight
|
|
71
|
+
if (hasWidth) variationSettings.wdth = selectedWidth
|
|
72
|
+
|
|
73
|
+
// Style dropdown: roman/italic when font has italic
|
|
74
|
+
const italicOptions = [
|
|
75
|
+
{ label: 'Roman', value: 'roman' },
|
|
76
|
+
{ label: 'Italic', value: 'italic' }
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<section className="w-full py-12 lg:py-16">
|
|
81
|
+
<div className="max-w-[1400px] mx-auto flex flex-col gap-8">
|
|
82
|
+
<SpecimenSectionHeader
|
|
83
|
+
selectedStyle={showDropdown ? selectedStyleVariant : showAxisDropdown ? selectedValue : undefined}
|
|
84
|
+
onStyleChange={showDropdown ? setSelectedStyleVariant : showAxisDropdown ? onValueChange : undefined}
|
|
85
|
+
showDropdown={showDropdown || (showAxisDropdown && valueOptions.length > 0)}
|
|
86
|
+
styleOptions={showDropdown ? italicOptions : valueOptions}
|
|
87
|
+
badgeText={badgeText}
|
|
88
|
+
icon="foundation"
|
|
89
|
+
size="sm"
|
|
90
|
+
showWeightDropdown={showAxisDropdown && valueOptions.length > 0}
|
|
91
|
+
weightOptions={valueOptions}
|
|
92
|
+
selectedWeight={selectedValue}
|
|
93
|
+
onWeightChange={onValueChange}
|
|
94
|
+
/>
|
|
95
|
+
|
|
96
|
+
<GlyphMetricsGrid
|
|
97
|
+
fontUrl={currentFontUrl}
|
|
98
|
+
fontFamily={fontFamily}
|
|
99
|
+
fontStyle="normal"
|
|
100
|
+
variationSettings={variationSettings}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default GlyphMetricsSection
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { usePrefersReducedMotion } from '@kolkrabbi/kol-component'
|
|
3
|
+
|
|
4
|
+
// Variable-font axis rest values — where every glyph sits with no pointer near.
|
|
5
|
+
const DEFAULTS = { wdth: 100, wght: 400, ital: 0, alpha: 1 }
|
|
6
|
+
const STATIC_VARIATION = "'wght' 400, 'wdth' 100, 'ital' 0.00"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* TextPressure — a single line of variable-font text whose glyphs deform
|
|
10
|
+
* toward the pointer: characters nearest the cursor grow widest / heaviest /
|
|
11
|
+
* most italic, and the effect falls off linearly with distance. A
|
|
12
|
+
* requestAnimationFrame loop lerps a smoothed follower toward the raw cursor
|
|
13
|
+
* (container-scoped listeners) and writes per-glyph `font-variation-settings`
|
|
14
|
+
* each frame. On mouse-leave the glyphs coast back to their rest axis values
|
|
15
|
+
* (`returnToDefault`) or freeze in place, then the loop auto-stops until the
|
|
16
|
+
* pointer returns. Ported from the react-bits / JuanFuentes effect, collapsed
|
|
17
|
+
* from three source variants into one.
|
|
18
|
+
*
|
|
19
|
+
* ── Font contract ──────────────────────────────────────────────────────────
|
|
20
|
+
* The effect only *moves* if the resolved font is a VARIABLE font exposing the
|
|
21
|
+
* `wght` / `wdth` / `ital` axes. The KOL variable font is a CONSUMER asset —
|
|
22
|
+
* the design system does not ship one. Two ways to supply it:
|
|
23
|
+
* • `fontUrl` given → an `@font-face` is injected at runtime for `fontFamily`
|
|
24
|
+
* (e.g. fontFamily="TG Root-Tune" fontUrl="/fonts/TGRoot-TuneVF.ttf").
|
|
25
|
+
* • `fontUrl` omitted → the already-loaded `fontFamily` is used as-is; the
|
|
26
|
+
* default `var(--kol-font-family-sans)` animates only if that theme font is
|
|
27
|
+
* itself variable — otherwise the text renders correctly but static.
|
|
28
|
+
*
|
|
29
|
+
* Reduced motion → no listeners, no rAF; glyphs render static at the rest axis
|
|
30
|
+
* values (wght 400 / wdth 100 / ital 0). Casing is authored by the caller —
|
|
31
|
+
* this component never text-transforms.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text the line; split into one <span> per character
|
|
34
|
+
* @param {string} fontFamily applied family + injected @font-face family
|
|
35
|
+
* @param {string} [fontUrl] variable-font URL → injects @font-face when set
|
|
36
|
+
* @param {boolean} width animate the `wdth` axis (5–150)
|
|
37
|
+
* @param {boolean} weight animate the `wght` axis (100–900)
|
|
38
|
+
* @param {boolean} italic animate the `ital` axis (0–1)
|
|
39
|
+
* @param {boolean} alpha animate per-glyph opacity (0–1)
|
|
40
|
+
* @param {boolean} flex distribute glyphs with justify-between
|
|
41
|
+
* @param {boolean} stroke add an outlined ghost layer behind each glyph
|
|
42
|
+
* @param {boolean} scale vertically scale the line to fill the container
|
|
43
|
+
* @param {string} textColor glyph color (KOL token / CSS color)
|
|
44
|
+
* @param {string} strokeColor stroke color when `stroke` is on
|
|
45
|
+
* @param {string} className extra classes on the <h1>
|
|
46
|
+
* @param {number} minFontSize floor for the responsive font size
|
|
47
|
+
* @param {number} [maxFontSize] cap for the responsive font size
|
|
48
|
+
* @param {boolean} returnToDefault true: coast back to rest on leave; false: freeze
|
|
49
|
+
*/
|
|
50
|
+
export default function TextPressure({
|
|
51
|
+
text = 'Compressa',
|
|
52
|
+
fontFamily = 'var(--kol-font-family-sans)',
|
|
53
|
+
fontUrl,
|
|
54
|
+
|
|
55
|
+
width = true,
|
|
56
|
+
weight = true,
|
|
57
|
+
italic = true,
|
|
58
|
+
alpha = false,
|
|
59
|
+
|
|
60
|
+
flex = true,
|
|
61
|
+
stroke = false,
|
|
62
|
+
scale = false,
|
|
63
|
+
|
|
64
|
+
textColor = 'var(--kol-fg-emphasis)',
|
|
65
|
+
strokeColor = 'var(--kol-accent-primary)',
|
|
66
|
+
className = '',
|
|
67
|
+
|
|
68
|
+
minFontSize = 24,
|
|
69
|
+
maxFontSize = null,
|
|
70
|
+
returnToDefault = true,
|
|
71
|
+
}) {
|
|
72
|
+
const containerRef = useRef(null)
|
|
73
|
+
const titleRef = useRef(null)
|
|
74
|
+
const spansRef = useRef([])
|
|
75
|
+
|
|
76
|
+
const mouseRef = useRef({ x: 0, y: 0 })
|
|
77
|
+
const cursorRef = useRef({ x: 0, y: 0 })
|
|
78
|
+
const currentValuesRef = useRef([])
|
|
79
|
+
const velocityRef = useRef([])
|
|
80
|
+
|
|
81
|
+
const [fontSize, setFontSize] = useState(minFontSize)
|
|
82
|
+
const [scaleY, setScaleY] = useState(1)
|
|
83
|
+
const [lineHeight, setLineHeight] = useState(1)
|
|
84
|
+
const [isHovering, setIsHovering] = useState(false)
|
|
85
|
+
|
|
86
|
+
const reducedMotion = usePrefersReducedMotion()
|
|
87
|
+
const chars = text.split('')
|
|
88
|
+
|
|
89
|
+
const dist = (a, b) => {
|
|
90
|
+
const dx = b.x - a.x
|
|
91
|
+
const dy = b.y - a.y
|
|
92
|
+
return Math.sqrt(dx * dx + dy * dy)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Container-scoped pointer tracking (never window). Skipped for reduced motion.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (reducedMotion) return undefined
|
|
98
|
+
const container = containerRef.current
|
|
99
|
+
if (!container) return undefined
|
|
100
|
+
|
|
101
|
+
const handleMouseMove = (e) => {
|
|
102
|
+
cursorRef.current.x = e.clientX
|
|
103
|
+
cursorRef.current.y = e.clientY
|
|
104
|
+
}
|
|
105
|
+
const handleTouchMove = (e) => {
|
|
106
|
+
const t = e.touches[0]
|
|
107
|
+
cursorRef.current.x = t.clientX
|
|
108
|
+
cursorRef.current.y = t.clientY
|
|
109
|
+
}
|
|
110
|
+
const handleMouseEnter = () => setIsHovering(true)
|
|
111
|
+
const handleMouseLeave = () => setIsHovering(false)
|
|
112
|
+
|
|
113
|
+
container.addEventListener('mousemove', handleMouseMove)
|
|
114
|
+
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
115
|
+
container.addEventListener('mouseenter', handleMouseEnter)
|
|
116
|
+
container.addEventListener('mouseleave', handleMouseLeave)
|
|
117
|
+
|
|
118
|
+
const { left, top, width: w, height: h } = container.getBoundingClientRect()
|
|
119
|
+
mouseRef.current.x = left + w / 2
|
|
120
|
+
mouseRef.current.y = top + h / 2
|
|
121
|
+
cursorRef.current.x = mouseRef.current.x
|
|
122
|
+
cursorRef.current.y = mouseRef.current.y
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
container.removeEventListener('mousemove', handleMouseMove)
|
|
126
|
+
container.removeEventListener('touchmove', handleTouchMove)
|
|
127
|
+
container.removeEventListener('mouseenter', handleMouseEnter)
|
|
128
|
+
container.removeEventListener('mouseleave', handleMouseLeave)
|
|
129
|
+
}
|
|
130
|
+
}, [reducedMotion])
|
|
131
|
+
|
|
132
|
+
// Responsive sizing (layout, not motion — runs regardless of reduced motion).
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const setSize = () => {
|
|
135
|
+
if (!containerRef.current || !titleRef.current) return
|
|
136
|
+
const { width: containerW, height: containerH } = containerRef.current.getBoundingClientRect()
|
|
137
|
+
|
|
138
|
+
let newFontSize = containerW / (chars.length / 2)
|
|
139
|
+
newFontSize = Math.max(newFontSize, minFontSize)
|
|
140
|
+
if (maxFontSize) newFontSize = Math.min(newFontSize, maxFontSize)
|
|
141
|
+
|
|
142
|
+
setFontSize(newFontSize)
|
|
143
|
+
setScaleY(1)
|
|
144
|
+
setLineHeight(1)
|
|
145
|
+
|
|
146
|
+
requestAnimationFrame(() => {
|
|
147
|
+
if (!titleRef.current) return
|
|
148
|
+
const textRect = titleRef.current.getBoundingClientRect()
|
|
149
|
+
if (scale && textRect.height > 0) {
|
|
150
|
+
const yRatio = containerH / textRect.height
|
|
151
|
+
setScaleY(yRatio)
|
|
152
|
+
setLineHeight(yRatio)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
setSize()
|
|
158
|
+
window.addEventListener('resize', setSize)
|
|
159
|
+
return () => window.removeEventListener('resize', setSize)
|
|
160
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
+
}, [scale, text, minFontSize, maxFontSize])
|
|
162
|
+
|
|
163
|
+
// The pressure loop. Skipped entirely for reduced motion (glyphs stay static).
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (reducedMotion) return undefined
|
|
166
|
+
let rafId
|
|
167
|
+
|
|
168
|
+
const animate = () => {
|
|
169
|
+
if (isHovering) {
|
|
170
|
+
mouseRef.current.x += (cursorRef.current.x - mouseRef.current.x) / 15
|
|
171
|
+
mouseRef.current.y += (cursorRef.current.y - mouseRef.current.y) / 15
|
|
172
|
+
|
|
173
|
+
if (titleRef.current && containerRef.current) {
|
|
174
|
+
const containerRect = containerRef.current.getBoundingClientRect()
|
|
175
|
+
const maxDist = containerRect.width / 2
|
|
176
|
+
|
|
177
|
+
spansRef.current.forEach((span, i) => {
|
|
178
|
+
if (!span) return
|
|
179
|
+
const rect = span.getBoundingClientRect()
|
|
180
|
+
const charCenter = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
|
181
|
+
const d = dist(mouseRef.current, charCenter)
|
|
182
|
+
|
|
183
|
+
const getAttr = (distance, minVal, maxVal) => {
|
|
184
|
+
const val = maxVal - Math.abs((maxVal * distance) / maxDist)
|
|
185
|
+
return Math.max(minVal, val + minVal)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const wdth = width ? Math.floor(getAttr(d, 5, 150)) : 100
|
|
189
|
+
const wght = weight ? Math.floor(getAttr(d, 100, 900)) : 400
|
|
190
|
+
const italVal = italic ? getAttr(d, 0, 1) : 0
|
|
191
|
+
const alphaVal = alpha ? getAttr(d, 0, 1) : 1
|
|
192
|
+
|
|
193
|
+
const prev = currentValuesRef.current[i]
|
|
194
|
+
if (prev && !returnToDefault) {
|
|
195
|
+
velocityRef.current[i] = {
|
|
196
|
+
wdth: wdth - prev.wdth,
|
|
197
|
+
wght: wght - prev.wght,
|
|
198
|
+
ital: italVal - prev.ital,
|
|
199
|
+
alpha: alphaVal - prev.alpha,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
currentValuesRef.current[i] = { wdth, wght, ital: italVal, alpha: alphaVal }
|
|
203
|
+
|
|
204
|
+
span.style.opacity = alphaVal
|
|
205
|
+
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal.toFixed(2)}`
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
// Coast back to rest (returnToDefault) or settle into a frozen pose.
|
|
210
|
+
let allSettled = true
|
|
211
|
+
|
|
212
|
+
spansRef.current.forEach((span, i) => {
|
|
213
|
+
if (!span) return
|
|
214
|
+
const current = currentValuesRef.current[i] || { ...DEFAULTS }
|
|
215
|
+
if (!velocityRef.current[i]) velocityRef.current[i] = { wdth: 0, wght: 0, ital: 0, alpha: 0 }
|
|
216
|
+
const velocity = velocityRef.current[i]
|
|
217
|
+
|
|
218
|
+
if (returnToDefault) {
|
|
219
|
+
const springStrength = 0.05
|
|
220
|
+
const damping = 0.88
|
|
221
|
+
velocity.wdth += (DEFAULTS.wdth - current.wdth) * springStrength
|
|
222
|
+
velocity.wght += (DEFAULTS.wght - current.wght) * springStrength
|
|
223
|
+
velocity.ital += (DEFAULTS.ital - current.ital) * springStrength
|
|
224
|
+
velocity.alpha += (DEFAULTS.alpha - current.alpha) * springStrength
|
|
225
|
+
velocity.wdth *= damping
|
|
226
|
+
velocity.wght *= damping
|
|
227
|
+
velocity.ital *= damping
|
|
228
|
+
velocity.alpha *= damping
|
|
229
|
+
|
|
230
|
+
const newWdth = current.wdth + velocity.wdth
|
|
231
|
+
const newWght = current.wght + velocity.wght
|
|
232
|
+
const newItal = current.ital + velocity.ital
|
|
233
|
+
const newAlpha = current.alpha + velocity.alpha
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
Math.abs(newWdth - DEFAULTS.wdth) > 0.5 ||
|
|
237
|
+
Math.abs(newWght - DEFAULTS.wght) > 0.5 ||
|
|
238
|
+
Math.abs(velocity.wdth) > 0.1 ||
|
|
239
|
+
Math.abs(velocity.wght) > 0.1
|
|
240
|
+
) {
|
|
241
|
+
allSettled = false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
currentValuesRef.current[i] = { wdth: newWdth, wght: newWght, ital: newItal, alpha: newAlpha }
|
|
245
|
+
span.style.opacity = newAlpha
|
|
246
|
+
span.style.fontVariationSettings = `'wght' ${Math.round(newWght)}, 'wdth' ${Math.round(newWdth)}, 'ital' ${newItal.toFixed(2)}`
|
|
247
|
+
} else {
|
|
248
|
+
const damping = 0.75
|
|
249
|
+
velocity.wdth *= damping
|
|
250
|
+
velocity.wght *= damping
|
|
251
|
+
velocity.ital *= damping
|
|
252
|
+
velocity.alpha *= damping
|
|
253
|
+
|
|
254
|
+
const newWdth = current.wdth + velocity.wdth
|
|
255
|
+
const newWght = current.wght + velocity.wght
|
|
256
|
+
const newItal = current.ital + velocity.ital
|
|
257
|
+
const newAlpha = current.alpha + velocity.alpha
|
|
258
|
+
|
|
259
|
+
if (
|
|
260
|
+
Math.abs(velocity.wdth) > 0.01 ||
|
|
261
|
+
Math.abs(velocity.wght) > 0.01 ||
|
|
262
|
+
Math.abs(velocity.ital) > 0.0001 ||
|
|
263
|
+
Math.abs(velocity.alpha) > 0.0001
|
|
264
|
+
) {
|
|
265
|
+
allSettled = false
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
currentValuesRef.current[i] = { wdth: newWdth, wght: newWght, ital: newItal, alpha: newAlpha }
|
|
269
|
+
span.style.opacity = newAlpha
|
|
270
|
+
span.style.fontVariationSettings = `'wght' ${Math.round(newWght)}, 'wdth' ${Math.round(newWdth)}, 'ital' ${newItal.toFixed(2)}`
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
if (allSettled) {
|
|
275
|
+
cancelAnimationFrame(rafId)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
rafId = requestAnimationFrame(animate)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
animate()
|
|
284
|
+
return () => cancelAnimationFrame(rafId)
|
|
285
|
+
}, [width, weight, italic, alpha, chars.length, isHovering, returnToDefault, reducedMotion])
|
|
286
|
+
|
|
287
|
+
const titleClass = [
|
|
288
|
+
'kol-text-pressure-title m-0 w-full max-w-full overflow-hidden box-border text-center whitespace-nowrap select-none origin-top font-thin',
|
|
289
|
+
flex ? 'flex justify-between' : '',
|
|
290
|
+
stroke ? 'kol-text-pressure--stroke' : '',
|
|
291
|
+
className,
|
|
292
|
+
]
|
|
293
|
+
.filter(Boolean)
|
|
294
|
+
.join(' ')
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div
|
|
298
|
+
ref={containerRef}
|
|
299
|
+
className="kol-text-pressure relative flex h-full w-full max-w-full items-center justify-center overflow-hidden"
|
|
300
|
+
>
|
|
301
|
+
{fontUrl && (
|
|
302
|
+
<style>{`@font-face { font-family: '${fontFamily}'; src: url('${fontUrl}'); font-style: normal; }`}</style>
|
|
303
|
+
)}
|
|
304
|
+
|
|
305
|
+
<h1
|
|
306
|
+
ref={titleRef}
|
|
307
|
+
className={titleClass}
|
|
308
|
+
style={{
|
|
309
|
+
fontFamily,
|
|
310
|
+
fontSize,
|
|
311
|
+
lineHeight,
|
|
312
|
+
transform: `scale(1, ${scaleY})`,
|
|
313
|
+
color: textColor,
|
|
314
|
+
'--kol-text-pressure-stroke': strokeColor,
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{chars.map((char, i) => (
|
|
318
|
+
<span
|
|
319
|
+
key={i}
|
|
320
|
+
ref={(el) => (spansRef.current[i] = el)}
|
|
321
|
+
data-char={char}
|
|
322
|
+
className="kol-text-pressure-char inline-block"
|
|
323
|
+
style={{ fontVariationSettings: STATIC_VARIATION }}
|
|
324
|
+
>
|
|
325
|
+
{char}
|
|
326
|
+
</span>
|
|
327
|
+
))}
|
|
328
|
+
</h1>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|