@milajs/breakpoints-aware
v0.1.8
Published
Container-query based element breakpoint detection using CSS animations and IntersectionObserver
Downloads
660
Maintainers
Readme
@milajs/breakpoints-aware
Element-level breakpoint detection powered by CSS container queries, CSS animations, and IntersectionObserver. No polling. No ResizeObserver. The browser's own container-query engine drives the detection.
How it works
- A hidden sentry container (with
container-type: inline-size) is injected inside the target element. - A sentry element inside it has CSS custom properties that change via
@containerqueries as the element resizes. - Each container query toggles between two alternating
@keyframesanimations, ensuring the animation restarts on every breakpoint transition. - The animation briefly moves the sentry into the
IntersectionObserverviewport, which fires theonMatchcallback. - The callback reads the cumulative
--matchesCSS custom property to know which breakpoints currently match.
Usage
import { onBreakpointsMatch, defaultBreakpoints } from '@milajs/breakpoints-aware';
const cleanup = onBreakpointsMatch('.my-card', {
breakpoints: defaultBreakpoints,
onMatch({ matches }) {
console.log(matches.current); // e.g. 'md'
console.log(matches.all); // e.g. ['xss', 'xs', 'sm', 'md']
console.log(matches.matches); // e.g. { xss: true, xs: true, sm: true, md: true, lg: false, ... }
},
});
// Later, to stop observing:
cleanup();Breakpoints & Mobile-First Logic
The library uses a strictly mobile-first approach. Under the hood, breakpoint ranges are compiled into min-width container query rules:
- The first (smallest) breakpoint always starts matching at
min-width: 0px. - Each subsequent breakpoint activates at
(previous breakpoint upper bound) + 1. - Once a breakpoint is activated, it remains matched as the element grows (matches accumulate in
result.all).
The Final Breakpoint matches to Infinity
Because the query thresholds are calculated relative to the previous breakpoint's upper bound, the final (largest) breakpoint defined will always match up to infinity (as there is no subsequent breakpoint to limit it).
The numeric value assigned to the final key represents its defined upper bound. If you add a larger breakpoint in the future, this value will be used to calculate the starting threshold of that new breakpoint. However, as long as it remains the last item in the list, the last breakpoint stays active indefinitely past the second-to-last breakpoint's boundary.
const defaultBreakpoints = {
xss: 320, // Active from 0px → 320px
xs: 480, // Active from 321px → 480px
sm: 690, // Active from 481px → 690px
md: 850, // Active from 691px → 850px
lg: 1124, // Active from 851px → 1124px
xl: 1380, // Active from 1125px → 1380px
xxl: 1920, // Active from 1381px → 1920px
xxl2: 2160, // Active from 1921px → ∞ (2160 acts as the upper bound if a larger key is added)
};API
onBreakpointsMatch<T extends Breakpoints>(ele, options)
| Param | Type | Description |
|-------|------|-------------|
| ele | string \| HTMLElement | CSS selector or element reference |
| options.breakpoints | T | Map of custom breakpoint names → upper bound widths |
| options.onMatch | (result: MatchesResult<T>) => void | Callback fired on every breakpoint transition |
Returns a CleanupFn that removes all injected DOM and styles.
MatchesResult<T extends Breakpoints>
| Property | Type | Description |
|----------|------|-------------|
| all | (keyof T)[] | All currently matching breakpoint names (ascending) |
| current | keyof T | The highest matching breakpoint name |
| matches | Record<keyof T, boolean> | Every breakpoint mapped to its match state |
TypeScript Type Safety
onBreakpointsMatch is fully generic. When you pass custom breakpoints (typically defined as as const), TypeScript automatically infers the keys of your breakpoints object and propagates them directly to the callback result:
const myBreakpoints = {
mobile: 480,
tablet: 768,
desktop: 1024,
} as const;
onBreakpointsMatch('.my-card', {
breakpoints: myBreakpoints,
onMatch(result) {
// result.current is strictly typed as 'mobile' | 'tablet' | 'desktop'
console.log(result.current);
// result.matches has strictly typed keys: Record<'mobile' | 'tablet' | 'desktop', boolean>
if (result.matches.tablet) {
console.log('We are on tablet or higher!');
}
},
});