@marcwiest/midday.js 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marc Wiest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,423 @@
1
+ # midday.js
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@marcwiest/midday.js)](https://www.npmjs.com/package/@marcwiest/midday.js)
4
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@marcwiest/midday.js)](https://bundlephobia.com/package/@marcwiest/midday.js)
5
+ [![CI](https://github.com/marcwiest/midday.js/actions/workflows/ci.yml/badge.svg)](https://github.com/marcwiest/midday.js/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ A modern, zero-dependency vanilla JS plugin for fixed headers that change style as you scroll through page sections. The spiritual successor to [midnight.js](https://github.com/Aerolab/midnight.js).
9
+
10
+ **~1 kB gzipped** (auto mode) | TypeScript | Framework adapters (React, Vue, Svelte, Solid) included
11
+
12
+ **[Live Demo](https://marcwiest.github.io/midday.js/)**
13
+
14
+ ## Background
15
+
16
+ [midnight.js](https://aerolab.github.io/midnight.js/) (2014) introduced a great UI pattern: a fixed header that smoothly transitions between visual styles as page sections scroll beneath it. The transition is a pixel-perfect wipe that follows the section boundary, not an abrupt class swap. midday.js implements the same effect using the browser APIs available today:
17
+
18
+ - **`clip-path: inset()`** for GPU-composited clipping (replaces the nested `overflow: hidden` + opposing `translateY` technique)
19
+ - **`ResizeObserver`** to track section dimensions reactively (replaces interval-based polling)
20
+ - **Scroll-triggered `requestAnimationFrame`** that idles when the user isn't scrolling
21
+ - **`aria-hidden` + `inert`** on cloned variants for screen reader and keyboard accessibility
22
+ - Full **`destroy()` / `refresh()`** lifecycle for clean teardown and dynamic content
23
+ - Zero dependencies, ~1 kB gzipped, TypeScript, framework adapters for React / Vue / Svelte / Solid
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install @marcwiest/midday.js
29
+ ```
30
+
31
+ Or via CDN (UMD):
32
+
33
+ ```html
34
+ <script src="https://unpkg.com/@marcwiest/midday.js/dist/midday.umd.js"></script>
35
+ ```
36
+
37
+ ## Quick Start
38
+
39
+ ### 1. Write one header, mark your sections
40
+
41
+ You write a single header. Each section declares which header style it wants via `data-midday-section`:
42
+
43
+ ```html
44
+ <header data-midday>
45
+ <nav>
46
+ <a href="/" class="logo">Logo</a>
47
+ <a href="/about">About</a>
48
+ <a href="/contact">Contact</a>
49
+ </nav>
50
+ </header>
51
+
52
+ <section data-midday-section="dark">
53
+ <!-- Dark hero — header should have white text here -->
54
+ </section>
55
+
56
+ <section>
57
+ <!-- No attribute — header uses its default style -->
58
+ </section>
59
+
60
+ <section data-midday-section="accent">
61
+ <!-- Purple section — header should match -->
62
+ </section>
63
+ ```
64
+
65
+ ### 2. Initialize
66
+
67
+ ```js
68
+ import { midday } from '@marcwiest/midday.js';
69
+
70
+ const instance = midday(document.querySelector('[data-midday]'));
71
+ ```
72
+
73
+ ### 3. What happens next
74
+
75
+ The plugin reads every unique `data-midday-section` value on the page (`"dark"`, `"accent"`) and clones your header once per variant. Each clone is wrapped in a container with a `data-midday-variant` attribute. Your original HTML stays as-is — the clones are created at runtime:
76
+
77
+ ```
78
+ <header data-midday> ← your element (position: fixed)
79
+ <div data-midday-variant="default"> ← default style (original content)
80
+ <nav>Logo, About, Contact</nav>
81
+ </div>
82
+ <div data-midday-variant="dark"> ← clone for "dark" sections
83
+ <nav>Logo, About, Contact</nav>
84
+ </div>
85
+ <div data-midday-variant="accent"> ← clone for "accent" sections
86
+ <nav>Logo, About, Contact</nav>
87
+ </div>
88
+ </header>
89
+ ```
90
+
91
+ As you scroll, the plugin shows and hides portions of each clone using `clip-path`, creating a smooth wipe transition at every section boundary.
92
+
93
+ ### 4. Style each variant
94
+
95
+ Target variant wrappers with `[data-midday-variant="..."]`. Style them however you want:
96
+
97
+ ```css
98
+ header {
99
+ position: fixed;
100
+ top: 0;
101
+ left: 0;
102
+ right: 0;
103
+ z-index: 100;
104
+ }
105
+
106
+ /* Shown over sections without data-midday-section */
107
+ [data-midday-variant="default"] {
108
+ background: white;
109
+ color: #111;
110
+ }
111
+
112
+ /* Shown over data-midday-section="dark" */
113
+ [data-midday-variant="dark"] {
114
+ background: #111;
115
+ color: white;
116
+ }
117
+
118
+ /* Shown over data-midday-section="accent" */
119
+ [data-midday-variant="accent"] {
120
+ background: linear-gradient(135deg, #667eea, #764ba2);
121
+ color: white;
122
+ }
123
+ ```
124
+
125
+ That's it. Scroll through your sections and the header transitions smoothly from one section to another.
126
+
127
+ ## API
128
+
129
+ ### `midday(header, options?)` — Auto mode
130
+
131
+ Clones your header content once per variant and manages everything. Sections are discovered automatically via `data-midday-section` attributes.
132
+
133
+ ```js
134
+ const instance = midday(document.querySelector('[data-midday]'));
135
+
136
+ // With optional onChange callback:
137
+ const instance = midday(document.querySelector('[data-midday]'), {
138
+ onChange: (variants) => console.log(variants),
139
+ });
140
+ ```
141
+
142
+ ### `middayHeadless(options)` — Headless mode
143
+
144
+ You provide pre-rendered variant elements. The plugin only manages `clip-path` values. No DOM cloning. Use this when you need **different markup** (not just different styles) per variant.
145
+
146
+ ```js
147
+ import { middayHeadless } from '@marcwiest/midday.js';
148
+
149
+ const instance = middayHeadless({
150
+ header: document.querySelector('header'),
151
+ variants: {
152
+ default: document.querySelector('.header-default'),
153
+ dark: document.querySelector('.header-dark'),
154
+ },
155
+ defaultVariant: 'default', // Which key in `variants` is the fallback (optional, defaults to 'default')
156
+ onChange: (variants) => {}, // Optional
157
+ });
158
+ ```
159
+
160
+ ### Instance methods
161
+
162
+ Both modes return the same instance:
163
+
164
+ ```js
165
+ instance.refresh(); // Re-scan sections and recalculate (call after DOM changes)
166
+ instance.destroy(); // Full teardown — removes clones, listeners, observers
167
+ ```
168
+
169
+ ### `onChange` callback
170
+
171
+ Fires whenever the set of visible variants changes:
172
+
173
+ ```js
174
+ midday(header, {
175
+ onChange: (variants) => {
176
+ // variants: Array<{ name: string, progress: number }>
177
+ // progress: 0–1, how much of the header this variant covers
178
+ console.log(variants);
179
+ // e.g. [{ name: 'dark', progress: 0.7 }, { name: 'default', progress: 0.3 }]
180
+ },
181
+ });
182
+ ```
183
+
184
+ ## Framework Adapters
185
+
186
+ Each adapter is a separate tree-shakable entry point (~0.2 kB gzipped). Import only the one you need — the others are never bundled.
187
+
188
+ The adapters wrap **auto mode** — your component renders a single header element, and cloning happens client-side on mount. This means your server-rendered HTML always contains just one header, keeping SEO clean (see [SSR & SEO](#ssr--seo) below).
189
+
190
+ ### React
191
+
192
+ ```jsx
193
+ import { useRef } from 'react';
194
+ import { useMidday } from '@marcwiest/midday.js/react';
195
+
196
+ function Header() {
197
+ const headerRef = useRef(null);
198
+ useMidday(headerRef);
199
+
200
+ return (
201
+ <header ref={headerRef} style={{ position: 'fixed', top: 0, left: 0, right: 0 }}>
202
+ <Nav />
203
+ </header>
204
+ );
205
+ }
206
+ ```
207
+
208
+ ### Vue
209
+
210
+ Composable:
211
+
212
+ ```html
213
+ <script setup>
214
+ import { ref } from 'vue';
215
+ import { useMidday } from '@marcwiest/midday.js/vue';
216
+
217
+ const headerRef = ref(null);
218
+ useMidday(headerRef);
219
+ </script>
220
+
221
+ <template>
222
+ <header ref="headerRef">
223
+ <Nav />
224
+ </header>
225
+ </template>
226
+ ```
227
+
228
+ Or as a directive (import as `vMidday` for auto-registration in `<script setup>`):
229
+
230
+ ```html
231
+ <script setup>
232
+ import { vMidday } from '@marcwiest/midday.js/vue';
233
+ </script>
234
+
235
+ <template>
236
+ <header v-midday>
237
+ <Nav />
238
+ </header>
239
+ </template>
240
+ ```
241
+
242
+ ### Svelte
243
+
244
+ ```html
245
+ <script>
246
+ import { midday } from '@marcwiest/midday.js/svelte';
247
+ </script>
248
+
249
+ <header use:midday>
250
+ <Nav />
251
+ </header>
252
+ ```
253
+
254
+ ### Solid
255
+
256
+ Primitive:
257
+
258
+ ```jsx
259
+ import { createMidday } from '@marcwiest/midday.js/solid';
260
+
261
+ function Header() {
262
+ let headerEl;
263
+ createMidday(() => headerEl);
264
+
265
+ return (
266
+ <header ref={headerEl} style={{ position: 'fixed', top: '0', left: '0', right: '0' }}>
267
+ <Nav />
268
+ </header>
269
+ );
270
+ }
271
+ ```
272
+
273
+ Or as a directive:
274
+
275
+ ```jsx
276
+ import { midday } from '@marcwiest/midday.js/solid';
277
+
278
+ function Header() {
279
+ return (
280
+ <header use:midday style={{ position: 'fixed', top: '0', left: '0', right: '0' }}>
281
+ <Nav />
282
+ </header>
283
+ );
284
+ }
285
+ ```
286
+
287
+ ### Passing options
288
+
289
+ All adapters accept `onChange`:
290
+
291
+ ```jsx
292
+ // React
293
+ useMidday(headerRef, { onChange: (v) => console.log(v) });
294
+
295
+ // Vue (composable)
296
+ useMidday(headerRef, { onChange: (v) => console.log(v) });
297
+
298
+ // Vue (directive)
299
+ <header v-midday="{ onChange: (v) => console.log(v) }"></header>
300
+
301
+ // Svelte
302
+ <header use:midday={{ onChange: (v) => console.log(v) }}></header>
303
+
304
+ // Solid (primitive)
305
+ createMidday(() => headerEl, { onChange: (v) => console.log(v) });
306
+
307
+ // Solid (directive)
308
+ <header use:midday={{ onChange: (v) => console.log(v) }}></header>
309
+ ```
310
+
311
+ ## Multiple Instances
312
+
313
+ midday.js supports multiple independent fixed elements on the same page (e.g., a top header and a bottom app-bar). Name each instance via the `data-midday` attribute and use `data-midday-target` on sections to control which instance they affect.
314
+
315
+ ```html
316
+ <header data-midday="top">...</header>
317
+ <nav class="app-bar" data-midday="bottom">...</nav>
318
+
319
+ <!-- Targets only the top header -->
320
+ <section data-midday-section="accent" data-midday-target="top">...</section>
321
+
322
+ <!-- Targets both (space-separated) -->
323
+ <section data-midday-section="inverted" data-midday-target="top bottom">...</section>
324
+
325
+ <!-- No target — applies to ALL instances -->
326
+ <section data-midday-section="dark">...</section>
327
+ ```
328
+
329
+ ```js
330
+ import { midday } from '@marcwiest/midday.js';
331
+
332
+ const top = midday(document.querySelector('[data-midday="top"]'));
333
+ const bottom = midday(document.querySelector('[data-midday="bottom"]'));
334
+ ```
335
+
336
+ Each instance runs its own engine and only reacts to its own sections. The instance name defaults to the element's `data-midday` attribute value, or you can set it explicitly via `options.name`.
337
+
338
+ ## SSR & SEO
339
+
340
+ midday.js is designed to be SSR-safe by default.
341
+
342
+ **Auto mode** (including all framework adapters) clones header content client-side on mount. The server-rendered HTML always contains a single, clean header element — no duplicate navigation links, no hidden clones. Search engine crawlers see exactly one header with one set of nav links.
343
+
344
+ After hydration, the plugin creates variant clones in the browser. These clones are marked with `aria-hidden="true"` and `inert`, so they're invisible to screen readers and excluded from keyboard navigation. The original header content remains the accessible version.
345
+
346
+ **Headless mode** is different — since you provide the variant elements yourself, they exist in your markup. If you're using headless mode with SSR, render non-default variants client-side only to avoid duplicate content in the server HTML:
347
+
348
+ ```jsx
349
+ // React (headless + SSR)
350
+ import { useState, useEffect } from 'react';
351
+ import { middayHeadless } from '@marcwiest/midday.js';
352
+
353
+ function Header() {
354
+ const [mounted, setMounted] = useState(false);
355
+ const headerRef = useRef(null);
356
+ const defaultRef = useRef(null);
357
+ const darkRef = useRef(null);
358
+
359
+ useEffect(() => setMounted(true), []);
360
+
361
+ useEffect(() => {
362
+ if (!mounted || !headerRef.current) return;
363
+ const instance = middayHeadless({
364
+ header: headerRef.current,
365
+ variants: { default: defaultRef.current, dark: darkRef.current },
366
+ });
367
+ return () => instance.destroy();
368
+ }, [mounted]);
369
+
370
+ return (
371
+ <header ref={headerRef}>
372
+ <div ref={defaultRef} className="header-default"><Nav /></div>
373
+ {mounted && (
374
+ <div ref={darkRef} className="header-dark" aria-hidden="true" inert="">
375
+ <Nav />
376
+ </div>
377
+ )}
378
+ </header>
379
+ );
380
+ }
381
+ ```
382
+
383
+ For most use cases, the framework adapters (which use auto mode) are simpler and SSR-safe without any extra work.
384
+
385
+ ## How It Works
386
+
387
+ midday.js uses `clip-path: inset()` to reveal and hide variant header elements as sections scroll past.
388
+
389
+ 1. **Auto mode** clones the header content once per unique variant found in `data-midday-section` attributes. Each clone is wrapped in an absolutely-positioned container inside the header element.
390
+
391
+ 2. On each scroll frame, the plugin calculates which sections overlap the header's viewport position and by how many pixels.
392
+
393
+ 3. Each variant's container gets a `clip-path: inset(topPx 0 bottomPx 0)` that reveals exactly the portion corresponding to its section's overlap with the header. Every variant — including the default — is clipped to only its own region. Nothing is used as a backdrop, so transparent backgrounds work fine.
394
+
395
+ The result is a pixel-perfect wipe transition at every section boundary.
396
+
397
+ ## Styling Guide
398
+
399
+ - The header element should be `position: fixed` or `position: sticky`
400
+ - In auto mode, variant wrappers get `data-midday-variant="<name>"` — target them with `[data-midday-variant="dark"]` or however you prefer
401
+ - Transparent variant backgrounds work — each variant is clipped independently, so page content shows through where intended
402
+ - In headless mode, you're responsible for positioning variant elements absolutely within the header and setting `aria-hidden`/`inert` on non-default variants
403
+ - In headless mode, variant elements can have different heights than the header. The clip-path will track section boundaries exactly, with extra height revealing only when the section extends past the header edge
404
+
405
+ ## Browser Support
406
+
407
+ Requires `clip-path: inset()` (97%+ global support), `ResizeObserver` (97%+), and `requestAnimationFrame`. Works in all modern browsers. No IE support.
408
+
409
+ ## Development
410
+
411
+ ```bash
412
+ pnpm install # Install dependencies
413
+ pnpm dev # Vite dev server (serves demo/ with HMR)
414
+ pnpm build # Full build: ESM + UMD + .d.ts
415
+ pnpm test # Run tests once
416
+ pnpm test:watch # Watch mode
417
+ ```
418
+
419
+ Tests use [Vitest](https://vitest.dev/) + [happy-dom](https://github.com/nicedayfor/happy-dom). Global mocks for `ResizeObserver` and `requestAnimationFrame` are in `tests/setup.ts` — see the comments there for details on the mock strategy.
420
+
421
+ ## License
422
+
423
+ MIT
package/dist/core.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { MiddayOptions, MiddayInstance } from './types';
2
+ export declare function createMidday(header: HTMLElement, options?: MiddayOptions): MiddayInstance;
package/dist/core.mjs ADDED
@@ -0,0 +1,194 @@
1
+ function k(t) {
2
+ const i = t.getBoundingClientRect();
3
+ return {
4
+ top: i.top + window.scrollY,
5
+ height: i.height
6
+ };
7
+ }
8
+ function z(t) {
9
+ const i = t.getBoundingClientRect();
10
+ return {
11
+ top: i.top,
12
+ height: i.height
13
+ };
14
+ }
15
+ function P(t) {
16
+ for (const i of t) {
17
+ const l = k(i.el);
18
+ i.top = l.top, i.height = l.height;
19
+ }
20
+ }
21
+ const G = "[data-midday-section]", K = "data-midday-section", J = "data-midday-target";
22
+ function Y(t) {
23
+ const i = document.querySelectorAll(G), l = [];
24
+ for (const s of i) {
25
+ const d = s.getAttribute(K);
26
+ if (!d) continue;
27
+ const v = s.getAttribute(J);
28
+ v && (!t || !v.split(" ").includes(t)) || l.push({ el: s, variant: d, top: 0, height: 0 });
29
+ }
30
+ return P(l), l;
31
+ }
32
+ function Q(t) {
33
+ let { header: i, variants: l, sections: s } = t;
34
+ const { defaultName: d, onChange: v } = t;
35
+ let a = null, p = !1, E = "", c = null;
36
+ function L() {
37
+ g();
38
+ }
39
+ function S() {
40
+ P(s), g();
41
+ }
42
+ function g() {
43
+ p || (p = !0, a = requestAnimationFrame(r));
44
+ }
45
+ function r() {
46
+ p = !1, h();
47
+ }
48
+ function h() {
49
+ const C = z(i), o = C.height;
50
+ if (o <= 0) return;
51
+ const W = window.scrollY, F = C.top, q = F + o, R = [], _ = /* @__PURE__ */ new Map();
52
+ let N = o, $ = 0, j = 0;
53
+ const U = /* @__PURE__ */ new Map();
54
+ for (const e of s) {
55
+ const T = e.top - W, m = T + e.height, M = Math.max(F, T), u = Math.min(q, m), B = Math.max(0, u - M);
56
+ if (B > 0) {
57
+ j += B;
58
+ const A = M - F, x = q - u;
59
+ N = Math.min(N, A), $ = Math.max($, o - x);
60
+ const I = _.get(e.variant);
61
+ I ? (I.topInset = Math.min(I.topInset, A), I.bottomInset = Math.min(I.bottomInset, x)) : _.set(e.variant, { topInset: A, bottomInset: x });
62
+ }
63
+ let f = U.get(e.variant);
64
+ f || (f = [], U.set(e.variant, f)), f.push({ viewTop: T, viewBottom: m });
65
+ }
66
+ let b = null;
67
+ for (const e of l) {
68
+ if (e.name === d) {
69
+ b = e.wrapper;
70
+ continue;
71
+ }
72
+ const T = U.get(e.name);
73
+ if (!T) {
74
+ e.wrapper.style.clipPath = "inset(0 0 100% 0)";
75
+ continue;
76
+ }
77
+ const m = e.wrapper.getBoundingClientRect(), M = m.height || o;
78
+ let u = M, B = M;
79
+ for (const f of T) {
80
+ const A = Math.max(m.top, f.viewTop), x = Math.min(m.bottom, f.viewBottom);
81
+ x <= A || (u = Math.min(u, A - m.top), B = Math.min(B, m.bottom - x));
82
+ }
83
+ if (u + B < M) {
84
+ e.wrapper.style.clipPath = `inset(${u}px 0 ${B}px 0)`;
85
+ const f = _.get(e.name), A = f ? (o - f.topInset - f.bottomInset) / o : 0;
86
+ R.push({ name: e.name, progress: A });
87
+ } else
88
+ e.wrapper.style.clipPath = "inset(0 0 100% 0)";
89
+ }
90
+ if (b) {
91
+ const e = b.getBoundingClientRect().height || o;
92
+ if (j >= o)
93
+ b.style.clipPath = "inset(0 0 100% 0)";
94
+ else if (j <= 0)
95
+ b.style.clipPath = "inset(0)", R.unshift({
96
+ name: d,
97
+ progress: 1
98
+ });
99
+ else {
100
+ const T = N;
101
+ if (o - $ >= T) {
102
+ const u = e * ($ / o);
103
+ b.style.clipPath = `inset(${u}px 0 0 0)`;
104
+ } else {
105
+ const u = e * ((o - N) / o);
106
+ b.style.clipPath = `inset(0 0 ${u}px 0)`;
107
+ }
108
+ const M = o - j;
109
+ R.unshift({
110
+ name: d,
111
+ progress: M / o
112
+ });
113
+ }
114
+ }
115
+ const D = R.map((e) => `${e.name}:${e.progress.toFixed(3)}`).join("|");
116
+ D !== E && (E = D, v == null || v(R));
117
+ }
118
+ function w() {
119
+ c == null || c.disconnect(), c = new ResizeObserver(() => {
120
+ P(s), g();
121
+ });
122
+ for (const C of s)
123
+ c.observe(C.el);
124
+ }
125
+ function n() {
126
+ window.addEventListener("scroll", L, { passive: !0 }), window.addEventListener("resize", S, { passive: !0 }), w(), g();
127
+ }
128
+ function V() {
129
+ P(s), g();
130
+ }
131
+ function H(C, o) {
132
+ l = C, s = o, P(s), w(), g();
133
+ }
134
+ function y() {
135
+ a !== null && cancelAnimationFrame(a), window.removeEventListener("scroll", L), window.removeEventListener("resize", S), c == null || c.disconnect(), c = null;
136
+ }
137
+ return n(), { recalculate: V, update: H, destroy: y };
138
+ }
139
+ const X = "data-midday-variant", O = "default";
140
+ function Z(t, i = {}) {
141
+ const { onChange: l } = i, s = i.name ?? (t.getAttribute("data-midday") || void 0), d = t.innerHTML, v = t.style.overflow;
142
+ let a = null, p = [];
143
+ function E() {
144
+ const r = Y(s), h = /* @__PURE__ */ new Set();
145
+ for (const y of r)
146
+ h.add(y.variant);
147
+ t.style.overflow = "visible";
148
+ const w = document.createDocumentFragment();
149
+ for (; t.firstChild; )
150
+ w.appendChild(t.firstChild);
151
+ const n = [];
152
+ for (const y of h)
153
+ n.push(c(y, w, !0));
154
+ const V = c(O, w, !1);
155
+ t.appendChild(V.wrapper);
156
+ const H = [V];
157
+ for (const y of n)
158
+ t.appendChild(y.wrapper), H.push(y);
159
+ return H;
160
+ }
161
+ function c(r, h, w) {
162
+ const n = document.createElement("div");
163
+ return n.setAttribute(X, r), n.style.position = "absolute", n.style.top = "0", n.style.left = "0", n.style.right = "0", n.style.bottom = "0", n.style.willChange = "clip-path", n.style.clipPath = "inset(0 0 100% 0)", w ? (n.setAttribute("aria-hidden", "true"), n.setAttribute("inert", ""), n.style.pointerEvents = "none", n.appendChild(h.cloneNode(!0))) : n.appendChild(h), { wrapper: n, name: r };
164
+ }
165
+ function L() {
166
+ const r = Y(s);
167
+ p = E(), a = Q({
168
+ header: t,
169
+ variants: p,
170
+ defaultName: O,
171
+ sections: r,
172
+ onChange: l
173
+ });
174
+ }
175
+ function S() {
176
+ for (const h of p)
177
+ h.wrapper.remove();
178
+ t.innerHTML = d;
179
+ const r = Y(s);
180
+ p = E(), a == null || a.update(p, r);
181
+ }
182
+ function g() {
183
+ a == null || a.destroy(), a = null;
184
+ for (const r of p)
185
+ r.wrapper.remove();
186
+ p = [], t.innerHTML = d, t.style.overflow = v;
187
+ }
188
+ return L(), { refresh: S, destroy: g };
189
+ }
190
+ export {
191
+ Z as a,
192
+ Q as c,
193
+ Y as s
194
+ };
@@ -0,0 +1,2 @@
1
+ import type { EngineConfig, Engine } from './types';
2
+ export declare function createEngine(config: EngineConfig): Engine;
@@ -0,0 +1,2 @@
1
+ import type { MiddayHeadlessOptions, MiddayInstance } from './types';
2
+ export declare function createMiddayHeadless(options: MiddayHeadlessOptions): MiddayInstance;
@@ -0,0 +1,24 @@
1
+ import type { MiddayOptions, MiddayHeadlessOptions, MiddayInstance, ActiveVariant } from './types';
2
+ /**
3
+ * Auto mode — Initialize midday.js on a fixed header element.
4
+ * Automatically clones header content for each variant and manages the DOM.
5
+ *
6
+ * @param header - The fixed/sticky header element
7
+ * @param options - Optional configuration
8
+ * @returns Instance with refresh() and destroy() methods
9
+ *
10
+ * @example
11
+ * ```js
12
+ * const instance = midday(document.querySelector('[data-midday]'));
13
+ * ```
14
+ */
15
+ export declare function midday(header: HTMLElement, options?: MiddayOptions): MiddayInstance;
16
+ /**
17
+ * Headless mode — Bring your own variant elements.
18
+ * Manages only clip-paths on pre-rendered elements. No DOM cloning.
19
+ *
20
+ * @param options - Header, variant elements, and optional config
21
+ * @returns Instance with refresh() and destroy() methods
22
+ */
23
+ export declare function middayHeadless(options: MiddayHeadlessOptions): MiddayInstance;
24
+ export type { MiddayOptions, MiddayHeadlessOptions, MiddayInstance, ActiveVariant };
@@ -0,0 +1,45 @@
1
+ import { s as i, c as y, a as p } from "./core.mjs";
2
+ function h(a) {
3
+ const {
4
+ header: e,
5
+ variants: c,
6
+ defaultVariant: o = "default",
7
+ name: r,
8
+ onChange: d
9
+ } = a;
10
+ let t = null;
11
+ function s() {
12
+ return Object.entries(c).map(([n, m]) => ({
13
+ name: n,
14
+ wrapper: m
15
+ }));
16
+ }
17
+ function u() {
18
+ const n = i(r);
19
+ t = y({
20
+ header: e,
21
+ variants: s(),
22
+ defaultName: o,
23
+ sections: n,
24
+ onChange: d
25
+ });
26
+ }
27
+ function f() {
28
+ const n = i(r);
29
+ t == null || t.update(s(), n);
30
+ }
31
+ function l() {
32
+ t == null || t.destroy(), t = null;
33
+ }
34
+ return u(), { refresh: f, destroy: l };
35
+ }
36
+ function M(a, e) {
37
+ return p(a, e);
38
+ }
39
+ function H(a) {
40
+ return h(a);
41
+ }
42
+ export {
43
+ M as midday,
44
+ H as middayHeadless
45
+ };
@@ -0,0 +1 @@
1
+ (function(C,I){typeof exports=="object"&&typeof module<"u"?I(exports):typeof define=="function"&&define.amd?define(["exports"],I):(C=typeof globalThis<"u"?globalThis:C||self,I(C.midday={}))})(this,(function(C){"use strict";function I(t){const o=t.getBoundingClientRect();return{top:o.top+window.scrollY,height:o.height}}function G(t){const o=t.getBoundingClientRect();return{top:o.top,height:o.height}}function V(t){for(const o of t){const l=I(o.el);o.top=l.top,o.height=l.height}}const K="[data-midday-section]",J="data-midday-section",Q="data-midday-target";function H(t){const o=document.querySelectorAll(K),l=[];for(const s of o){const u=s.getAttribute(J);if(!u)continue;const m=s.getAttribute(Q);m&&(!t||!m.split(" ").includes(t))||l.push({el:s,variant:u,top:0,height:0})}return V(l),l}function D(t){let{header:o,variants:l,sections:s}=t;const{defaultName:u,onChange:m}=t;let n=null,c=!1,B="",r=null;function R(){v()}function h(){V(s),v()}function v(){c||(c=!0,n=requestAnimationFrame(d))}function d(){c=!1,g()}function g(){const P=G(o),a=P.height;if(a<=0)return;const ot=window.scrollY,U=P.top,k=U+a,L=[],Y=new Map;let F=a,_=0,O=0;const q=new Map;for(const e of s){const M=e.top-ot,w=M+e.height,b=Math.max(U,M),p=Math.min(k,w),E=Math.max(0,p-b);if(E>0){O+=E;const A=b-U,S=k-p;F=Math.min(F,A),_=Math.max(_,a-S);const N=Y.get(e.variant);N?(N.topInset=Math.min(N.topInset,A),N.bottomInset=Math.min(N.bottomInset,S)):Y.set(e.variant,{topInset:A,bottomInset:S})}let f=q.get(e.variant);f||(f=[],q.set(e.variant,f)),f.push({viewTop:M,viewBottom:w})}let x=null;for(const e of l){if(e.name===u){x=e.wrapper;continue}const M=q.get(e.name);if(!M){e.wrapper.style.clipPath="inset(0 0 100% 0)";continue}const w=e.wrapper.getBoundingClientRect(),b=w.height||a;let p=b,E=b;for(const f of M){const A=Math.max(w.top,f.viewTop),S=Math.min(w.bottom,f.viewBottom);S<=A||(p=Math.min(p,A-w.top),E=Math.min(E,w.bottom-S))}if(p+E<b){e.wrapper.style.clipPath=`inset(${p}px 0 ${E}px 0)`;const f=Y.get(e.name),A=f?(a-f.topInset-f.bottomInset)/a:0;L.push({name:e.name,progress:A})}else e.wrapper.style.clipPath="inset(0 0 100% 0)"}if(x){const e=x.getBoundingClientRect().height||a;if(O>=a)x.style.clipPath="inset(0 0 100% 0)";else if(O<=0)x.style.clipPath="inset(0)",L.unshift({name:u,progress:1});else{const M=F;if(a-_>=M){const p=e*(_/a);x.style.clipPath=`inset(${p}px 0 0 0)`}else{const p=e*((a-F)/a);x.style.clipPath=`inset(0 0 ${p}px 0)`}const b=a-O;L.unshift({name:u,progress:b/a})}}const z=L.map(e=>`${e.name}:${e.progress.toFixed(3)}`).join("|");z!==B&&(B=z,m==null||m(L))}function y(){r==null||r.disconnect(),r=new ResizeObserver(()=>{V(s),v()});for(const P of s)r.observe(P.el)}function i(){window.addEventListener("scroll",R,{passive:!0}),window.addEventListener("resize",h,{passive:!0}),y(),v()}function j(){V(s),v()}function $(P,a){l=P,s=a,V(s),y(),v()}function T(){n!==null&&cancelAnimationFrame(n),window.removeEventListener("scroll",R),window.removeEventListener("resize",h),r==null||r.disconnect(),r=null}return i(),{recalculate:j,update:$,destroy:T}}const X="data-midday-variant",W="default";function Z(t,o={}){const{onChange:l}=o,s=o.name??(t.getAttribute("data-midday")||void 0),u=t.innerHTML,m=t.style.overflow;let n=null,c=[];function B(){const d=H(s),g=new Set;for(const T of d)g.add(T.variant);t.style.overflow="visible";const y=document.createDocumentFragment();for(;t.firstChild;)y.appendChild(t.firstChild);const i=[];for(const T of g)i.push(r(T,y,!0));const j=r(W,y,!1);t.appendChild(j.wrapper);const $=[j];for(const T of i)t.appendChild(T.wrapper),$.push(T);return $}function r(d,g,y){const i=document.createElement("div");return i.setAttribute(X,d),i.style.position="absolute",i.style.top="0",i.style.left="0",i.style.right="0",i.style.bottom="0",i.style.willChange="clip-path",i.style.clipPath="inset(0 0 100% 0)",y?(i.setAttribute("aria-hidden","true"),i.setAttribute("inert",""),i.style.pointerEvents="none",i.appendChild(g.cloneNode(!0))):i.appendChild(g),{wrapper:i,name:d}}function R(){const d=H(s);c=B(),n=D({header:t,variants:c,defaultName:W,sections:d,onChange:l})}function h(){for(const g of c)g.wrapper.remove();t.innerHTML=u;const d=H(s);c=B(),n==null||n.update(c,d)}function v(){n==null||n.destroy(),n=null;for(const d of c)d.wrapper.remove();c=[],t.innerHTML=u,t.style.overflow=m}return R(),{refresh:h,destroy:v}}function tt(t){const{header:o,variants:l,defaultVariant:s="default",name:u,onChange:m}=t;let n=null;function c(){return Object.entries(l).map(([h,v])=>({name:h,wrapper:v}))}function B(){const h=H(u);n=D({header:o,variants:c(),defaultName:s,sections:h,onChange:m})}function r(){const h=H(u);n==null||n.update(c(),h)}function R(){n==null||n.destroy(),n=null}return B(),{refresh:r,destroy:R}}function et(t,o){return Z(t,o)}function nt(t){return tt(t)}C.midday=et,C.middayHeadless=nt,Object.defineProperty(C,Symbol.toStringTag,{value:"Module"})}));
@@ -0,0 +1,7 @@
1
+ import type { MiddayOptions, MiddayInstance } from './types';
2
+ /**
3
+ * React hook for midday.js (auto mode).
4
+ * Initializes on mount, destroys on unmount.
5
+ * Cloning happens client-side — safe for SSR.
6
+ */
7
+ export declare function useMidday(headerRef: React.RefObject<HTMLElement | null>, options?: MiddayOptions): React.RefObject<MiddayInstance | null>;
package/dist/react.mjs ADDED
@@ -0,0 +1,15 @@
1
+ import { useRef as u, useEffect as c } from "react";
2
+ import { a as o } from "./core.mjs";
3
+ function s(t, n) {
4
+ const r = u(null);
5
+ return c(() => {
6
+ if (t.current)
7
+ return r.current = o(t.current, n), () => {
8
+ var e;
9
+ (e = r.current) == null || e.destroy(), r.current = null;
10
+ };
11
+ }, []), r;
12
+ }
13
+ export {
14
+ s as useMidday
15
+ };
@@ -0,0 +1,21 @@
1
+ import type { MiddayOptions, MiddayInstance } from './types';
2
+ /**
3
+ * Solid primitive for midday.js (auto mode).
4
+ * Initializes on mount, cleans up on disposal.
5
+ * Cloning happens client-side — safe for SSR.
6
+ */
7
+ export declare function createMidday(headerAccessor: () => HTMLElement | null, options?: MiddayOptions): () => MiddayInstance | null;
8
+ /**
9
+ * Solid directive for midday.js (auto mode).
10
+ * Usage: <header use:midday> or <header use:midday={{ onChange }}>
11
+ *
12
+ * TypeScript: extend JSX.DirectiveFunctions in your app:
13
+ * declare module "solid-js" {
14
+ * namespace JSX {
15
+ * interface DirectiveFunctions {
16
+ * midday: typeof import('midday.js/solid').midday;
17
+ * }
18
+ * }
19
+ * }
20
+ */
21
+ export declare function midday(el: HTMLElement, accessor: () => MiddayOptions | undefined): void;
package/dist/solid.mjs ADDED
@@ -0,0 +1,21 @@
1
+ import { onMount as d, onCleanup as e } from "solid-js";
2
+ import { a } from "./core.mjs";
3
+ function c(n, o) {
4
+ let t = null;
5
+ return d(() => {
6
+ const r = n();
7
+ r && (t = a(r, o));
8
+ }), e(() => {
9
+ t == null || t.destroy(), t = null;
10
+ }), () => t;
11
+ }
12
+ function l(n, o) {
13
+ const t = a(n, o());
14
+ e(() => {
15
+ t.destroy();
16
+ });
17
+ }
18
+ export {
19
+ c as createMidday,
20
+ l as midday
21
+ };
@@ -0,0 +1,12 @@
1
+ import type { MiddayOptions } from './types';
2
+ /**
3
+ * Svelte action for midday.js (auto mode).
4
+ * Initializes when the element mounts, destroys when it unmounts.
5
+ * Cloning happens client-side — safe for SSR.
6
+ *
7
+ * Usage: <header use:midday> or <header use:midday={{ onChange }}>
8
+ */
9
+ export declare function midday(node: HTMLElement, options?: MiddayOptions): {
10
+ update: (opts?: MiddayOptions) => void;
11
+ destroy: () => void;
12
+ };
@@ -0,0 +1,15 @@
1
+ import { a as t } from "./core.mjs";
2
+ function u(d, o) {
3
+ let r = t(d, o);
4
+ return {
5
+ update(e) {
6
+ r == null || r.destroy(), r = t(d, e);
7
+ },
8
+ destroy() {
9
+ r == null || r.destroy(), r = null;
10
+ }
11
+ };
12
+ }
13
+ export {
14
+ u as midday
15
+ };
@@ -0,0 +1,52 @@
1
+ export interface ActiveVariant {
2
+ /** The variant name (from data-midday-section value) */
3
+ name: string;
4
+ /** How much of the header this variant covers (0 to 1) */
5
+ progress: number;
6
+ }
7
+ export interface MiddayInstance {
8
+ /** Re-scan sections and recalculate positions. Call after DOM changes. */
9
+ refresh: () => void;
10
+ /** Full teardown: remove clones, observers, listeners, restore original DOM. */
11
+ destroy: () => void;
12
+ }
13
+ export interface SectionData {
14
+ el: Element;
15
+ variant: string;
16
+ top: number;
17
+ height: number;
18
+ }
19
+ export interface VariantState {
20
+ wrapper: HTMLElement;
21
+ name: string;
22
+ }
23
+ export interface MiddayOptions {
24
+ /** Instance name for multi-instance scoping. Sections with a matching data-midday-target will be claimed by this instance. Defaults to the header's data-midday attribute value. */
25
+ name?: string;
26
+ /** Called when the set of visible variants changes */
27
+ onChange?: ((variants: ActiveVariant[]) => void) | null;
28
+ }
29
+ export interface MiddayHeadlessOptions {
30
+ /** The fixed/sticky header element (used for position calculations) */
31
+ header: HTMLElement;
32
+ /** Map of variant name to wrapper element. The plugin manages clip-paths on these. */
33
+ variants: Record<string, HTMLElement>;
34
+ /** Which key in `variants` is the default (shown where no section overlaps). Defaults to 'default'. */
35
+ defaultVariant?: string;
36
+ /** Instance name for multi-instance scoping. Sections with a matching data-midday-target will be claimed by this instance. */
37
+ name?: string;
38
+ /** Called when the set of visible variants changes */
39
+ onChange?: ((variants: ActiveVariant[]) => void) | null;
40
+ }
41
+ export interface EngineConfig {
42
+ header: HTMLElement;
43
+ variants: VariantState[];
44
+ defaultName: string;
45
+ sections: SectionData[];
46
+ onChange?: ((variants: ActiveVariant[]) => void) | null;
47
+ }
48
+ export interface Engine {
49
+ recalculate: () => void;
50
+ update: (variants: VariantState[], sections: SectionData[]) => void;
51
+ destroy: () => void;
52
+ }
@@ -0,0 +1,27 @@
1
+ import type { SectionData } from './types';
2
+ /**
3
+ * Get a section's position relative to the document.
4
+ * Uses getBoundingClientRect + scrollY for accuracy.
5
+ */
6
+ export declare function getSectionBounds(el: Element): {
7
+ top: number;
8
+ height: number;
9
+ };
10
+ /**
11
+ * Get the header's current viewport-relative bounding rect.
12
+ * This accounts for CSS transforms, sticky offsets, etc.
13
+ */
14
+ export declare function getHeaderBounds(el: HTMLElement): {
15
+ top: number;
16
+ height: number;
17
+ };
18
+ /**
19
+ * Calculate cached bounds for all tracked sections.
20
+ */
21
+ export declare function cacheSectionBounds(sections: SectionData[]): void;
22
+ /**
23
+ * Scan the document for sections, optionally filtered by instance name.
24
+ * - Sections without data-midday-target apply to ALL instances.
25
+ * - Sections with data-midday-target apply only to the listed instance(s) (space-separated).
26
+ */
27
+ export declare function scanSections(instanceName?: string): SectionData[];
package/dist/vue.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type Ref, type ObjectDirective } from 'vue';
2
+ import type { MiddayOptions, MiddayInstance } from './types';
3
+ /**
4
+ * Vue composable for midday.js (auto mode).
5
+ * Initializes on mount, destroys on unmount.
6
+ * Cloning happens client-side — safe for SSR.
7
+ */
8
+ export declare function useMidday(headerRef: Ref<HTMLElement | null>, options?: MiddayOptions): Ref<MiddayInstance | null>;
9
+ /**
10
+ * Vue custom directive for midday.js (auto mode).
11
+ * Usage: <header v-midday> or <header v-midday="{ onChange }">
12
+ * In <script setup>, import as `vMidday` for auto-registration.
13
+ */
14
+ export declare const vMidday: ObjectDirective<HTMLElement, MiddayOptions | undefined>;
package/dist/vue.mjs ADDED
@@ -0,0 +1,25 @@
1
+ import { shallowRef as o, onMounted as u, onUnmounted as s } from "vue";
2
+ import { a as d } from "./core.mjs";
3
+ function r(n, e) {
4
+ const t = o(null);
5
+ return u(() => {
6
+ n.value && (t.value = d(n.value, e));
7
+ }), s(() => {
8
+ var a;
9
+ (a = t.value) == null || a.destroy(), t.value = null;
10
+ }), t;
11
+ }
12
+ const c = {
13
+ mounted(n, e) {
14
+ const t = d(n, e.value);
15
+ n.__middayInstance = t;
16
+ },
17
+ unmounted(n) {
18
+ var e;
19
+ (e = n.__middayInstance) == null || e.destroy(), delete n.__middayInstance;
20
+ }
21
+ };
22
+ export {
23
+ r as useMidday,
24
+ c as vMidday
25
+ };
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "@marcwiest/midday.js",
3
+ "version": "0.1.0",
4
+ "description": "A modern vanilla JS plugin for fixed headers that change style as you scroll through sections. Zero dependencies. The spiritual successor to midnight.js.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/marcwiest/midday.js.git"
11
+ },
12
+ "homepage": "https://github.com/marcwiest/midday.js#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/marcwiest/midday.js/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "./dist/midday.umd.js",
18
+ "module": "./dist/midday.mjs",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/midday.mjs",
24
+ "require": "./dist/midday.umd.js"
25
+ },
26
+ "./react": {
27
+ "types": "./dist/react.d.ts",
28
+ "import": "./dist/react.mjs"
29
+ },
30
+ "./vue": {
31
+ "types": "./dist/vue.d.ts",
32
+ "import": "./dist/vue.mjs"
33
+ },
34
+ "./svelte": {
35
+ "types": "./dist/svelte.d.ts",
36
+ "import": "./dist/svelte.mjs"
37
+ },
38
+ "./solid": {
39
+ "types": "./dist/solid.d.ts",
40
+ "import": "./dist/solid.mjs"
41
+ }
42
+ },
43
+ "files": [
44
+ "dist"
45
+ ],
46
+ "scripts": {
47
+ "dev": "vite",
48
+ "build": "vite build && vite build --config vite.config.umd.ts && tsc --emitDeclarationOnly",
49
+ "preview": "vite preview",
50
+ "build:demo": "vite build --config vite.config.demo.ts",
51
+ "test": "vitest run",
52
+ "test:watch": "vitest",
53
+ "prepublishOnly": "pnpm build && pnpm test"
54
+ },
55
+ "keywords": [
56
+ "header",
57
+ "fixed-header",
58
+ "scroll",
59
+ "clip-path",
60
+ "navigation",
61
+ "midnight.js",
62
+ "vanilla-js",
63
+ "no-dependencies",
64
+ "react",
65
+ "vue",
66
+ "svelte",
67
+ "solid"
68
+ ],
69
+ "license": "MIT",
70
+ "peerDependencies": {
71
+ "react": ">=18",
72
+ "solid-js": ">=1",
73
+ "vue": ">=3"
74
+ },
75
+ "peerDependenciesMeta": {
76
+ "react": {
77
+ "optional": true
78
+ },
79
+ "vue": {
80
+ "optional": true
81
+ },
82
+ "solid-js": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "devDependencies": {
87
+ "@types/react": "^19.2.14",
88
+ "happy-dom": "^20.6.3",
89
+ "solid-js": "^1.9.11",
90
+ "typescript": "^5.7.0",
91
+ "vite": "^6.1.0",
92
+ "vitest": "^4.0.18",
93
+ "vue": "^3.5.28"
94
+ }
95
+ }