The complete, SSR-safe, framework-agnostic responsive toolkit for TypeScript.
Typed breakpoints · Fluid typography · Container queries
User preferences · Server rendering · Zero dependencies
# npm / yarn / pnpm
npm install fluidity-ts
Problem · Quick Start · Why fluidity-ts? · Architecture · Recipes · SSR · API · Contributing
──────────── ✦ ────────────
Building responsive UIs in 2026 means duct-taping 5+ packages together — a media-query hook, a window-size hook, a fluid-type calculator, a container-query polyfill, a UA sniffer. Each ships its own hydration footguns, its own global state, its own untyped API. You end up with 22 KB of overlapping deps and a graveyard of typeof window !== "undefined" checks.
fluidity-ts replaces all of them with one library. One import, one provider, one type system — from breakpoint detection to fluid typography to server-side rendering.
| Capability | react-responsive | react-use | usehooks-ts | react-device-detect | fluidity-ts |
|---|---|---|---|---|---|
| SSR-safe (zero hydration warnings) | ❌ | ⚠️ | ❌ | ❌ | ✅ |
| Typed breakpoint inference | ❌ | ❌ | ❌ | ❌ | ✅ |
Runtime fluidClamp() / fluid scale |
❌ | ❌ | ❌ | ❌ | ✅ |
| Container queries | ❌ | ❌ | ❌ | ❌ | ✅ |
prefers-reduced-data / forced-colors |
❌ | ❌ | ❌ | ❌ | ✅ |
| Client Hints / SSR breakpoint resolver | ❌ | ❌ | ❌ | ❌ | ✅ |
| Framework-agnostic core | ❌ | ❌ | ❌ | ❌ | ✅ |
| Actively maintained (2026) | ⚠️ | ⚠️ | ✅ | ❌ (abandoned) | ✅ |
──────────── ✦ ────────────
// App.tsx
import { ResponsiveProvider, useBreakpoint, useResponsiveValue, Show } from "fluidity-ts/react";
import { fluidClamp } from "fluidity-ts/styles";
function App() {
const bp = useBreakpoint();
// bp.active → "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
// bp.is("md"), bp.above("lg"), bp.below("xl"), bp.between("sm", "lg")
const cols = useResponsiveValue({ xs: 1, md: 2, xl: 4 });
return (
<main style={{ fontSize: fluidClamp({ minPx: 16, maxPx: 22 }) }}>
<p>Breakpoint: <strong>{bp.active}</strong></p>
<Grid columns={cols} />
<Show above="md">
<Sidebar />
</Show>
<Show below="md" fallback={<DesktopNav />}>
<MobileMenu />
</Show>
</main>
);
}
export default () => (
<ResponsiveProvider serverWidth={1024}>
<App />
</ResponsiveProvider>
);
<script setup lang="ts">
import { useBreakpoint } from 'fluidity-ts/vue';
const bp = useBreakpoint();
</script>
<template>
<FullNav v-if="bp.above('desktop')" />
<HamburgerMenu v-else />
</template>
<script>
import { breakpoint } from 'fluidity-ts/svelte';
const bp = breakpoint();
</script>
{#if $bp === 'desktop'}
<FullNav />
{:else}
<HamburgerMenu />
{/if}
──────────── ✦ ────────────
🔒 Truly SSR-SafeEvery hook uses |
🎯 Typed Breakpoints
|
📐 Fluid Typography
|
🖥️ Server Rendering
|
♿ Accessibility-First
|
🧩 Framework-AgnosticVanilla core works in Vue, Svelte, Solid, or plain JS. React adapter is opt-in via |
──────────── ✦ ────────────
fluidity-ts
├── core/ ← Framework-agnostic primitives (no React, no DOM assumptions)
│ ├── breakpoints createBreakpoints(), defaultBreakpoints, resolve/up/down/between/only
│ ├── media watchMedia(), mq.* (prebuilt media query strings)
│ ├── viewport observeViewport(), getViewport(), visual viewport API
│ ├── container observeContainer(), getContainerSize(), matchesContainerRange()
│ ├── preferences observePreference(), getAllPreferences()
│ ├── pointer observePointerCapabilities(), getPointerCapabilities()
│ ├── dpr observeDevicePixelRatio(), getDevicePixelRatio()
│ ├── safe-area observeSafeArea(), getSafeArea()
│ ├── responsive resolveResponsive() — pick value by breakpoint
│ └── store createFluidityStore() — shared reactive state
│
├── react/ ← React adapter (opt-in, uses useSyncExternalStore)
│ ├── ResponsiveProvider context provider with serverWidth/serverHeight
│ ├── useBreakpoint active breakpoint + is/above/below/between helpers
│ ├── useMediaQuery SSR-safe matchMedia
│ ├── useViewport { width, height, orientation }
│ ├── useResponsiveValue resolve breakpoint-keyed values
│ ├── usePreference reduced-motion, dark mode, forced-colors…
│ ├── usePointer hover, coarse, fine detection
│ ├── useDevicePixelRatio retina detection
│ ├── useSafeArea env(safe-area-inset-*)
│ ├── useContainerQuery ResizeObserver-based container queries
│ ├── useDynamicViewport dvh/svh/lvh in pixels
│ ├── Show / Hide declarative breakpoint rendering
│ └── BreakpointBadge dev overlay (breakpoint + viewport size)
│
├── vue/ ← Vue 3 composables
├── svelte/ ← Svelte stores
├── styles/ ← Pure-string CSS helpers (no DOM, no side effects)
│ ├── fluidClamp / fluidScale CSS clamp() generation
│ ├── containerQuery @container rule builder
│ ├── responsiveStyle breakpoint → media-query style objects
│ ├── safeAreaInset / Padding env() safe-area helpers
│ ├── dvh / svh / lvh dynamic viewport unit helpers
│ ├── printOnly / screenOnly print media helpers
│ ├── visuallyHidden screen-reader-only styles
│ └── logical physical → logical property mapper
│
├── server/ ← Node.js / edge runtime
│ ├── resolveBreakpointFromHints Client Hints → breakpoint
│ ├── resolveBreakpointFromUA User-Agent fallback
│ ├── resolveServerBreakpoint tries hints, then UA
│ └── clientHintsResponseHeaders Accept-CH / Critical-CH headers
│
├── testing/ ← Test utilities for downstream consumers
│ ├── installMatchMediaMock controllable matchMedia
│ ├── installResizeObserverMock controllable ResizeObserver
│ └── setWindowSize resize + dispatch
│
└── tailwind/ ← Tailwind CSS integration
└── tailwindPreset sync breakpoints → Tailwind screens
──────────── ✦ ────────────
| Import | Description | Size (gzip) |
|---|---|---|
fluidity-ts |
Vanilla core — breakpoints, media, viewport, container, preferences, pointer, DPR, safe-area, store | ~2.4 KB |
fluidity-ts/react |
React hooks + components — everything above, reactive | ~3.3 KB |
fluidity-ts/vue |
Vue 3 composables | — |
fluidity-ts/svelte |
Svelte stores | — |
fluidity-ts/styles |
CSS helpers — fluidClamp, containerQuery, responsiveStyle, safeArea, print, a11y | ~1.3 KB |
fluidity-ts/server |
Server resolver — Client Hints + UA → breakpoint + width | ~0.8 KB |
fluidity-ts/testing |
Test mocks — matchMedia, ResizeObserver, setWindowSize | — |
fluidity-ts/tailwind |
Tailwind preset — sync your breakpoints to Tailwind screens | — |
All entries are tree-shakeable (sideEffects: false), ship ESM + CJS, and have full TypeScript declarations.
──────────── ✦ ────────────
Replace copy-pasted CSS from utopia.fyi with a typed function:
// styles/typography.ts
import { fluidClamp, fluidScale } from "fluidity-ts/styles";
// Single value
const fontSize = fluidClamp({ minPx: 16, maxPx: 22, minVwPx: 360, maxVwPx: 1280 });
// → "clamp(1rem, 0.8rem + 0.625vw, 1.375rem)"
// Full type scale
const scale = fluidScale(["sm", "base", "lg", "xl", "2xl"], {
minPx: 14,
ratio: 1.2,
});
// → { sm: "clamp(...)", base: "clamp(...)", lg: "clamp(...)", ... }
No polyfill needed — native ResizeObserver under the hood:
// components/Card.tsx
import { useRef } from "react";
import { useContainerQuery, useContainerSize } from "fluidity-ts/react";
function Card() {
const ref = useRef<HTMLDivElement>(null);
const isWide = useContainerQuery(ref, { minPx: 480 });
const size = useContainerSize(ref);
return (
<div ref={ref}>
{isWide ? <HorizontalLayout /> : <StackedLayout />}
<span>{size.width}×{size.height}</span>
</div>
);
}
Respect every user preference — all typed, all SSR-safe:
// app/App.tsx
import { usePreference } from "fluidity-ts/react";
function App() {
const reducedMotion = usePreference("reduced-motion");
const reducedData = usePreference("reduced-data");
const forcedColors = usePreference("forced-colors");
const darkMode = usePreference("dark");
return (
<div className={darkMode ? "dark" : "light"}>
{reducedData ? <LowResImage /> : <HighResImage />}
<AnimatedHero animate={!reducedMotion} />
</div>
);
}
Declarative show/hide based on breakpoints:
// components/navigation.tsx
import { Show, Hide } from "fluidity-ts/react";
<Show above="md">
<DesktopSidebar />
</Show>
<Show below="md" fallback={<DesktopNav />}>
<MobileMenu />
</Show>
<Show between={["sm", "lg"]}>
<TabletSpecificWidget />
</Show>
<Hide above="xl">
<CompactFooter />
</Hide>
Drop a breakpoint badge in your app during development:
// app/devtools.tsx
import { BreakpointBadge } from "fluidity-ts/react";
// Shows "md · 768×1024" in the corner — auto-hidden in production
<BreakpointBadge position="bottom-right" />
Define your own breakpoint system with full type inference:
// breakpoints.ts
import { createBreakpoints } from "fluidity-ts";
const bp = createBreakpoints({
mobile: 0,
tablet: 600,
desktop: 1024,
wide: 1440,
} as const);
bp.resolve(800); // → "tablet"
bp.up("desktop"); // → "(min-width: 1024px)"
bp.between("tablet", "wide"); // → "(min-width: 600px) and (max-width: 1439.98px)"
Share breakpoints between fluidity-ts and Tailwind CSS:
// tailwind.config.ts
import { tailwindPreset } from "fluidity-ts/tailwind";
import { defaultBreakpoints } from "fluidity-ts";
export default {
presets: [tailwindPreset({ breakpoints: defaultBreakpoints })],
};
The core works everywhere — no React required:
// responsive.ts
import { createBreakpoints, observeViewport, watchMedia, observePreference } from "fluidity-ts";
const bp = createBreakpoints({ sm: 640, md: 768, lg: 1024 } as const);
// Subscribe to viewport changes
const unsub = observeViewport(({ width, height }) => {
console.log(`${bp.resolve(width)} — ${width}×${height}`);
});
// Watch a media query
const mq = watchMedia("(prefers-color-scheme: dark)");
mq.subscribe((matches) => console.log("Dark mode:", matches));
// Watch user preferences
observePreference("reducedMotion", (on) => {
document.body.classList.toggle("no-motion", on);
});
──────────── ✦ ────────────
// app/layout.tsx
import { headers } from "next/headers";
import { resolveBreakpointFromHints } from "fluidity-ts/server";
import { ResponsiveProvider } from "fluidity-ts/react";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const { width } = resolveBreakpointFromHints(h);
return (
<html lang="en">
<body>
<ResponsiveProvider serverWidth={width}>
{children}
</ResponsiveProvider>
</body>
</html>
);
}
// next.config.ts — opt the browser into Client Hints
export default {
async headers() {
return [{
source: "/:path*",
headers: [
{ key: "Accept-CH", value: "Sec-CH-Viewport-Width, Sec-CH-UA-Mobile" },
{ key: "Critical-CH", value: "Sec-CH-Viewport-Width, Sec-CH-UA-Mobile" },
],
}];
},
};
// server.ts
import { resolveServerBreakpoint, clientHintsResponseHeaders } from "fluidity-ts/server";
// Add Client Hints headers to responses
app.use((req, res, next) => {
for (const [key, value] of clientHintsResponseHeaders) {
res.setHeader(key, value);
}
next();
});
// Resolve breakpoint from incoming request
app.get("/", (req, res) => {
const { breakpoint, width } = resolveServerBreakpoint(req.headers);
// breakpoint → "md", width → 768
});
──────────── ✦ ────────────
fluidity-ts ships test utilities so your component tests don't need a real browser:
// vitest.setup.ts (or jest.setup.ts)
import {
installMatchMediaMock,
installResizeObserverMock,
setWindowSize,
} from "fluidity-ts/testing";
const matchMedia = installMatchMediaMock();
const resizeObserver = installResizeObserverMock();
// In your tests:
setWindowSize(768, 1024); // Simulate tablet viewport
matchMedia.set("(prefers-color-scheme: dark)", true);
resizeObserver.resize(myElement, { width: 500, height: 300 });
──────────── ✦ ────────────
| Browser | Minimum Version |
|---|---|
| Chrome / Edge | Last 2 versions |
| Firefox | Last 2 versions |
| Safari | 16+ |
Container queries: Safari 16+, Chromium 105+, Firefox 110+.
prefers-reduced-data: Chromium-only — gracefully returns false elsewhere.
──────────── ✦ ────────────
| Entry | Min + gzip |
|---|---|
fluidity-ts (core) |
~2.4 KB |
fluidity-ts/react |
~3.3 KB |
fluidity-ts/styles |
~1.3 KB |
fluidity-ts/server |
~0.8 KB |
| Total (all entries) | ~7.8 KB |
Bundle budgets are enforced in CI via size-limit. Every PR that exceeds the budget fails.
──────────── ✦ ────────────
fluidity-ts| Export | Type | Description |
|---|---|---|
defaultBreakpoints |
const |
{ xs: 0, sm: 640, md: 768, lg: 1024, xl: 1280, "2xl": 1536 } |
createBreakpoints(map) |
fn |
Create a typed breakpoint system with resolve, up, down, between, only |
watchMedia(query) |
fn |
SSR-safe matchMedia wrapper — .matches(), .subscribe() |
mq |
const |
Prebuilt media query strings for common patterns |
observeViewport(listener) |
fn |
Subscribe to window resize/orientation changes |
getViewport() |
fn |
Snapshot { width, height, orientation } |
getVisualViewport() |
fn |
Visual viewport snapshot (pinch-zoom aware) |
observeVisualViewport(listener) |
fn |
Subscribe to visual viewport changes |
observeContainer(el, listener) |
fn |
ResizeObserver-based container size subscription |
getContainerSize(el) |
fn |
Sync container size snapshot |
matchesContainerRange(size, range) |
fn |
Check if container matches { minPx?, maxPx? } |
observePreference(key, listener) |
fn |
Watch reducedMotion, dark, forcedColors, etc. |
getAllPreferences() |
fn |
Snapshot of all preference booleans |
observePointerCapabilities(listener) |
fn |
Watch hover/coarse/fine pointer changes |
observeDevicePixelRatio(listener) |
fn |
Watch DPR changes (display switch, zoom) |
observeSafeArea(listener) |
fn |
Watch env(safe-area-inset-*) changes |
resolveResponsive(system, value, width) |
fn |
Pick value from breakpoint-keyed map |
createFluidityStore(system, opts) |
fn |
Shared reactive store for any framework |
fluidity-ts/react| Export | Type | Description |
|---|---|---|
<ResponsiveProvider> |
component |
Context provider — serverWidth, serverHeight, custom system |
useBreakpoint() |
hook |
Returns { active, is, above, below, between } |
useMediaQuery(query, serverDefault?) |
hook |
SSR-safe matchMedia boolean |
useViewport() |
hook |
{ width, height, orientation } |
useResponsiveValue(map) |
hook |
Resolve { xs: 1, md: 2, xl: 4 } → current value |
usePreference(key, serverDefault?) |
hook |
"reduced-motion" | "dark" | "forced-colors" | … |
usePointer(serverDefault?) |
hook |
{ hover, anyHover, coarse, fine } |
useDevicePixelRatio(serverDefault?) |
hook |
Current DPR (retina = 2, etc.) |
useSafeArea(serverDefault?) |
hook |
{ top, right, bottom, left } in px |
useContainerQuery(ref, range, serverDefault?) |
hook |
Boolean — does container match width range? |
useContainerSize(ref, serverDefault?) |
hook |
{ width, height } of container element |
useDynamicViewport(serverDefault?) |
hook |
{ dvh, svh, lvh } in px |
<Show> |
component |
Conditional render: on, above, below, between, fallback |
<Hide> |
component |
Inverse of <Show> |
<BreakpointBadge> |
component |
Dev overlay — auto-hidden in production |
fluidity-ts/vue| Export | Type | Description |
|---|---|---|
useBreakpoint() |
hook |
Returns { active, is, above, below, between } |
useMediaQuery(query, serverDefault?) |
hook |
SSR-safe reactive media query boolean |
useContainerQuery(elRef, range, serverDefault?) |
hook |
Boolean — does template ref match width range? |
useContainerSize(elRef, serverDefault?) |
hook |
{ width, height } of the container element |
useColorScheme(options?) |
hook |
Returns { colorScheme, isDark, setColorScheme } with optional persistence |
usePreference(key, serverDefault?) |
hook |
"reduced-motion" | "dark" | "forced-colors" | … |
useViewport() |
hook |
{ width, height, orientation } |
useDevicePixelRatio(serverDefault?) |
hook |
Current DPR (retina = 2, etc.) |
usePointer(serverDefault?) |
hook |
{ hover, anyHover, coarse, fine } |
useSafeArea(serverDefault?) |
hook |
{ top, right, bottom, left } in px |
createFluidityPlugin(options?) |
plugin |
App plugin — provide system, serverWidth, serverHeight |
fluidity-ts/svelte| Export | Type | Description |
|---|---|---|
breakpoint(system?) |
store |
Active breakpoint store + .is(), .above(), .below(), .between() derived stores |
mediaQuery(query, serverDefault?) |
store |
SSR-safe reactive media query boolean |
containerQuery(el, range, serverDefault?) |
store |
Boolean — does container match width range? |
containerSize(el, serverDefault?) |
store |
{ width, height } of the container element |
colorScheme(options?) |
store |
Returns { scheme, isDark, set } with optional persistence |
preference(key, serverDefault?) |
store |
"reduced-motion" | "dark" | "forced-colors" | … |
viewport() |
store |
{ width, height, orientation } |
devicePixelRatio(serverDefault?) |
store |
Current DPR (retina = 2, etc.) |
pointer(serverDefault?) |
store |
{ hover, anyHover, coarse, fine } stores |
safeArea(serverDefault?) |
store |
{ top, right, bottom, left } in px |
fluidity-ts/styles| Export | Type | Description |
|---|---|---|
fluidClamp(opts) |
fn |
Generate CSS clamp() for fluid sizing |
fluidScale(steps, opts) |
fn |
Build a named fluid type scale |
containerQuery(opts) |
fn |
Build @container rule string |
defineContainer(name?) |
fn |
CSS for container-type / container-name |
responsiveStyle(system, prop, values) |
fn |
Breakpoint → media-query style objects |
safeAreaInset(side, fallbackPx) |
fn |
CSS env(safe-area-inset-*) with fallback |
safeAreaPadding(fallbackPx) |
fn |
All-sides safe-area padding |
dvh / svh / lvh |
fn |
Dynamic viewport unit helpers |
printOnly / screenOnly |
const |
Media query strings |
printStyle(declarations) |
fn |
Wrap styles in @media print |
visuallyHidden |
const |
Screen-reader-only style object |
visuallyHiddenCss |
const |
Screen-reader-only CSS string |
touchTargetMinPx |
const |
Touch target minimums (wcag, apple, material) |
logical |
const |
Physical → logical property name map |
toLogical(styles) |
fn |
Convert physical CSS props to logical equivalents |
fluidity-ts/server| Export | Type | Description |
|---|---|---|
resolveBreakpointFromHints(headers, system?) |
fn |
Client Hints → breakpoint + width |
resolveBreakpointFromUserAgent(ua, system?) |
fn |
UA-sniff fallback (mobile/desktop guess) |
resolveServerBreakpoint(input, system?) |
fn |
Tries hints, falls back to UA |
clientHintsResponseHeaders |
const |
Accept-CH + Critical-CH header entries |
fluidity-ts/testing| Export | Type | Description |
|---|---|---|
installMatchMediaMock(initial?) |
fn |
Controllable matchMedia — .set(), .reset(), .uninstall() |
installResizeObserverMock() |
fn |
Controllable ResizeObserver — .resize(), .uninstall() |
setWindowSize(width, height) |
fn |
Resize window + dispatch resize event |
fluidity-ts/tailwind| Export | Type | Description |
|---|---|---|
tailwindPreset(system) |
fn |
Tailwind preset that mirrors your breakpoints as screens |
──────────── ✦ ────────────
We'd love your help. Check out CONTRIBUTING.md for the full guide.
# local development
git clone https://github.com/fluidiety/fluidity-ts && cd fluidity-ts
npm install
npm run verify # typecheck + lint + test + build + publint + attw + size
Look for good first issue to get started. We follow the Contributor Covenant.
──────────── ✦ ────────────
MIT © Tamish Mhatre and fluidity-ts contributors.
If fluidity-ts saves you time, consider giving it a ⭐