npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@marcwiest/midday.js

v0.2.2

Published

A modern vanilla JS plugin for fixed headers that change style as you scroll through sections. Zero dependencies. The spiritual successor to midnight.js.

Readme

midday.js

npm version npm bundle size CI License: MIT

A modern, zero-dependency vanilla JS plugin for fixed elements that change style as you scroll through page sections. The spiritual successor to midnight.js.

~1 kB gzipped (auto mode) | TypeScript | Framework adapters (React, Vue, Svelte, Solid) included

Live Demo

Background

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:

  • clip-path: inset() for GPU-composited clipping (replaces the nested overflow: hidden + opposing translateY technique)
  • ResizeObserver to track section dimensions reactively (replaces interval-based polling)
  • Scroll-triggered requestAnimationFrame that idles when the user isn't scrolling
  • aria-hidden + inert on cloned variants for screen reader and keyboard accessibility
  • Full destroy() / refresh() lifecycle for clean teardown and dynamic content
  • Zero dependencies, ~1 kB gzipped, TypeScript, framework adapters for React / Vue / Svelte / Solid

Install

npm install @marcwiest/midday.js

Or via CDN (UMD):

<script src="https://unpkg.com/@marcwiest/midday.js/dist/midday.umd.js"></script>

Quick Start

1. Mark your element, add your sections

Add data-midday-element to your fixed element. Each section declares which variant it wants via data-midday-section:

<header data-midday-element>
  <nav>
    <a href="/" class="logo">Logo</a>
    <a href="/about">About</a>
    <a href="/contact">Contact</a>
  </nav>
</header>

<section data-midday-section="dark">
  <!-- Dark hero — header should have white text here -->
</section>

<section>
  <!-- No attribute — header uses its default style -->
</section>

<section data-midday-section="accent">
  <!-- Purple section — header should match -->
</section>

2. Initialize

import { midday } from '@marcwiest/midday.js';

const instance = midday(document.querySelector('[data-midday-element]'));

3. What happens next

The plugin reads every unique data-midday-section value on the page ("dark", "accent") and clones your element's content 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:

<header data-midday-element>                       ← your element (position: fixed)
  <div data-midday-variant="default">              ← default style (original content)
    <nav>Logo, About, Contact</nav>
  </div>
  <div data-midday-variant="dark">                 ← clone for "dark" sections
    <nav>Logo, About, Contact</nav>
  </div>
  <div data-midday-variant="accent">               ← clone for "accent" sections
    <nav>Logo, About, Contact</nav>
  </div>
</header>

As you scroll, the plugin shows and hides portions of each clone using clip-path, creating a smooth wipe transition at every section boundary.

4. Style each variant

Target variant wrappers with [data-midday-variant="..."]. Style them however you want:

header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
}

/* Shown over sections without data-midday-section */
[data-midday-variant="default"] {
  background: white;
  color: #111;
}

/* Shown over data-midday-section="dark" */
[data-midday-variant="dark"] {
  background: #111;
  color: white;
}

/* Shown over data-midday-section="accent" */
[data-midday-variant="accent"] {
  background: linear-gradient(135deg, #667eea, #764ba2);
  color: white;
}

That's it. Scroll through your sections and the element transitions smoothly from one variant to another.

API

midday(element, options?) — Auto mode

Clones your element's content once per variant and manages everything. Sections are discovered automatically via data-midday-section attributes.

const instance = midday(document.querySelector('[data-midday-element]'));

// With optional onChange callback:
const instance = midday(document.querySelector('[data-midday-element]'), {
  onChange: (variants) => console.log(variants),
});

middayHeadless(options) — Headless mode

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.

import { middayHeadless } from '@marcwiest/midday.js';

const instance = middayHeadless({
  element: document.querySelector('header'),
  variants: {
    default: document.querySelector('.header-default'),
    dark: document.querySelector('.header-dark'),
  },
  defaultVariant: 'default',  // Which key in `variants` is the fallback (optional, defaults to 'default')
  onChange: (variants) => {},  // Optional
});

Instance methods

Both modes return the same instance:

instance.refresh();  // Rebuild variants and re-scan sections
instance.destroy();  // Full teardown — removes clones, listeners, observers

When to call refresh() — in both modes, call it when sections are added, removed, or reordered in the DOM. Beyond that, the two modes differ:

Auto mode clones element content at init time. The clones and an internal sizing ghost are frozen snapshots of the element's DOM. CSS-driven size changes (media queries, viewport resize, font loading) are handled automatically — the sizing ghost is a real DOM node in normal flow and reflows with the page. But if the element's HTML content changes (nav items added/removed, conditional elements toggled), call refresh() to rebuild the clones and sizing ghost from the current DOM.

Framework adapters initialize once on mount and don't auto-detect content changes. If your element's content is dynamic, call refresh() after updates:

// React example
const instance = useMidday(elementRef);

useEffect(() => {
  instance.current?.refresh();
}, [navItems]);

Section changes are not auto-detected. The library uses ResizeObserver to track section and element sizes, but it does not watch for new or removed [data-midday-section] elements. In SPAs or pages with dynamic sections, call refresh() after route changes or any DOM mutation that adds, removes, or reorders sections.

Headless mode doesn't manage element DOM — you own the variant elements. The engine reads element and variant sizes live on every scroll frame via getBoundingClientRect(), so size changes to your variant elements are picked up automatically. You only need refresh() when sections change, since section bounds are cached.

onChange callback

Fires whenever the set of visible variants changes:

midday(element, {
  onChange: (variants) => {
    // variants: Array<{ name: string, progress: number }>
    // progress: 0–1, how much of the element this variant covers
    console.log(variants);
    // e.g. [{ name: 'dark', progress: 0.7 }, { name: 'default', progress: 0.3 }]
  },
});

Framework Adapters

Each adapter is a separate tree-shakable entry point (~0.2 kB gzipped). Import only the one you need — the others are never bundled.

The adapters wrap auto mode — your component renders a single element, and cloning happens client-side on mount. This means your server-rendered HTML stays clean (see SSR & SEO below).

React

import { useRef } from 'react';
import { useMidday } from '@marcwiest/midday.js/react';

function Header() {
  const elementRef = useRef(null);
  useMidday(elementRef);

  return (
    <header ref={elementRef} style={{ position: 'fixed', top: 0, left: 0, right: 0 }}>
      <Nav />
    </header>
  );
}

Vue

Composable:

<script setup>
import { ref } from 'vue';
import { useMidday } from '@marcwiest/midday.js/vue';

const elementRef = ref(null);
useMidday(elementRef);
</script>

<template>
  <header ref="elementRef">
    <Nav />
  </header>
</template>

Or as a directive (import as vMidday for auto-registration in <script setup>):

<script setup>
import { vMidday } from '@marcwiest/midday.js/vue';
</script>

<template>
  <header v-midday>
    <Nav />
  </header>
</template>

Svelte

<script>
  import { midday } from '@marcwiest/midday.js/svelte';
</script>

<header use:midday>
  <Nav />
</header>

Solid

Primitive:

import { createMidday } from '@marcwiest/midday.js/solid';

function Header() {
  let el;
  createMidday(() => el);

  return (
    <header ref={el} style={{ position: 'fixed', top: '0', left: '0', right: '0' }}>
      <Nav />
    </header>
  );
}

Or as a directive:

import { midday } from '@marcwiest/midday.js/solid';

function Header() {
  return (
    <header use:midday style={{ position: 'fixed', top: '0', left: '0', right: '0' }}>
      <Nav />
    </header>
  );
}

Passing options

All adapters accept onChange:

// React
useMidday(elementRef, { onChange: (v) => console.log(v) });

// Vue (composable)
useMidday(elementRef, { onChange: (v) => console.log(v) });

// Vue (directive)
<header v-midday="{ onChange: (v) => console.log(v) }"></header>

// Svelte
<header use:midday={{ onChange: (v) => console.log(v) }}></header>

// Solid (primitive)
createMidday(() => el, { onChange: (v) => console.log(v) });

// Solid (directive)
<header use:midday={{ onChange: (v) => console.log(v) }}></header>

Multiple Instances

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-element attribute and use data-midday-target on sections to control which instance they affect.

<header data-midday-element="top">...</header>
<nav class="app-bar" data-midday-element="bottom">...</nav>

<!-- Targets only the top element -->
<section data-midday-section="accent" data-midday-target="top">...</section>

<!-- Targets both (space-separated) -->
<section data-midday-section="inverted" data-midday-target="top bottom">...</section>

<!-- No target — applies to ALL instances -->
<section data-midday-section="dark">...</section>
import { midday } from '@marcwiest/midday.js';

const top = midday(document.querySelector('[data-midday-element="top"]'));
const bottom = midday(document.querySelector('[data-midday-element="bottom"]'));

Each instance runs its own engine and only reacts to its own sections. The instance name defaults to the element's data-midday-element attribute value, or you can set it explicitly via options.name.

SSR & SEO

midday.js is designed to be SSR-safe by default.

Auto mode (including all framework adapters) clones element content client-side on mount. The server-rendered HTML always contains a single, clean element — no duplicate navigation links, no hidden clones. Search engine crawlers see exactly one set of content.

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 content remains the accessible version.

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:

// React (headless + SSR)
import { useState, useEffect } from 'react';
import { middayHeadless } from '@marcwiest/midday.js';

function Header() {
  const [mounted, setMounted] = useState(false);
  const elementRef = useRef(null);
  const defaultRef = useRef(null);
  const darkRef = useRef(null);

  useEffect(() => setMounted(true), []);

  useEffect(() => {
    if (!mounted || !elementRef.current) return;
    const instance = middayHeadless({
      element: elementRef.current,
      variants: { default: defaultRef.current, dark: darkRef.current },
    });
    return () => instance.destroy();
  }, [mounted]);

  return (
    <header ref={elementRef}>
      <div ref={defaultRef} className="header-default"><Nav /></div>
      {mounted && (
        <div ref={darkRef} className="header-dark" aria-hidden="true" inert="">
          <Nav />
        </div>
      )}
    </header>
  );
}

For most use cases, the framework adapters (which use auto mode) are simpler and SSR-safe without any extra work.

How It Works

midday.js uses clip-path: inset() to reveal and hide variant elements as sections scroll past.

  1. Auto mode clones the element's content once per unique variant found in data-midday-section attributes. Each clone is wrapped in an absolutely-positioned container inside the managed element.

  2. On each scroll frame, the plugin calculates which sections overlap the element's viewport position and by how many pixels.

  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 element. Every variant — including the default — is clipped to only its own region. Nothing is used as a backdrop, so transparent backgrounds work fine.

The result is a pixel-perfect wipe transition at every section boundary.

Styling Guide

  • The managed element should be position: fixed or position: sticky
  • In auto mode, variant wrappers get data-midday-variant="<name>" — target them with [data-midday-variant="dark"] or however you prefer
  • Transparent variant backgrounds work — each variant is clipped independently, so page content shows through where intended
  • In headless mode, you're responsible for positioning variant elements absolutely within the managed element and setting aria-hidden/inert on non-default variants
  • In headless mode, variant elements can have different heights than the managed element. The clip-path will track section boundaries exactly, with extra height revealing only when the section extends past the element edge
  • Auto mode uses cloneNode(true) to create variant copies. This duplicates DOM structure and attributes but not JavaScript event listeners attached via addEventListener. If your element contains interactive elements (nav links, dropdowns, etc.), use event delegation — attach a single listener to document and match with closest() — so events work in all variants

Overflow Content (Dropdowns, Flyouts)

clip-path: inset(...) clips all descendants, including position: fixed elements like dropdown panels. In auto mode, cloneNode(true) also duplicates dropdown markup into every variant. There is no library-level fix for this, but the workaround is straightforward:

Keep triggers inside the element; render panels outside the element's DOM entirely.

Position the panel as a sibling of the element (or in a portal container) and align it visually with its trigger. Since triggers get cloned into each variant, use event delegation to handle clicks:

document.addEventListener('click', (e) => {
  const trigger = e.target.closest('.dropdown-trigger');
  if (!trigger) return;
  const panel = document.querySelector('#dropdown-panel');
  const rect = trigger.getBoundingClientRect();
  panel.style.top = rect.bottom + 'px';
  panel.style.left = rect.left + 'px';
  panel.classList.toggle('open');
});

CSS Anchor Positioning offers a progressive enhancement: set anchor-name on the trigger and use position-anchor + position: fixed on the panel. This works across DOM subtrees without JavaScript positioning. Browser support is still limited (Chromium 125+).

Framework portals solve this idiomatically: React's createPortal, Vue's <Teleport to="body">, Solid's <Portal>, or a Svelte portal library. Render the dropdown panel into document.body so it sits outside the clipped element entirely.

Browser Support

Requires clip-path: inset() (97%+ global support), ResizeObserver (97%+), and requestAnimationFrame. Works in all modern browsers. No IE support.

Development

pnpm install          # Install dependencies
pnpm dev              # Vite dev server (serves demo/ with HMR)
pnpm build            # Full build: ESM + UMD + .d.ts
pnpm test             # Run tests once
pnpm test:watch       # Watch mode

Tests use Vitest + happy-dom. Global mocks for ResizeObserver and requestAnimationFrame are in tests/setup.ts — see the comments there for details on the mock strategy.

License

MIT