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

fluidity-ts

v1.3.0

Published

The complete, SSR-safe, framework-agnostic responsive toolkit for TypeScript: typed breakpoints, fluid clamp, container queries, modern preference media, and a first-class React adapter — zero runtime deps.

Downloads

563

Readme

# npm / yarn / pnpm
npm install fluidity-ts

The Problem

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.

How It Compares

| Capability | react-responsive | react-use | usehooks-ts | use-media | @vueuse/core | 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) | |

Framework Support

React, Vue, and Svelte ship dedicated entry points today. Everything else plugs into the same framework-agnostic core, SSR utilities, or React-compatible adapter surface.

Quick Start

React

// 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>
);

Vue 3

<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>

Svelte

<script>
  import { breakpoint } from 'fluidity-ts/svelte';
  const bp = breakpoint();
</script>

{#if $bp === 'desktop'}
  <FullNav />
{:else}
  <HamburgerMenu />
{/if}

Why fluidity-ts?

🔒 Truly SSR-Safe

Every hook uses useSyncExternalStore with getServerSnapshot. Pair with <ResponsiveProvider serverWidth={…}> — correct breakpoint on first paint, zero hydration mismatch.

🎯 Typed Breakpoints

createBreakpoints({ sm: 640, md: 768 } as const) gives literal-typed keys everywhere — autocomplete in hooks, <Show>, responsiveStyle, and more.

📐 Fluid Typography

fluidClamp() generates CSS clamp() at runtime — no more copy-pasting from utopia.fyi. Includes inverted-slope guard and a fluidScale() builder for full type scales.

🖥️ Server Rendering

resolveBreakpointFromHints(headers) reads Sec-CH-Viewport-Width / Sec-CH-UA-Mobile with UA fallback. Works with Next.js, Hono, Express — any server framework.

♿ Accessibility-First

prefers-reduced-motion, prefers-reduced-data, prefers-contrast, forced-colors, inverted-colors — all typed, all SSR-safe, all first-class citizens.

🧩 Framework-Agnostic

Vanilla core works in Vue, Svelte, Solid, or plain JS. React adapter is opt-in via fluidity-ts/react. Use just the core — it has zero dependencies.

What's Included

One toolkit, split into focused modules you can adopt independently.

| Module | Includes | | :--- | :--- | | Core | breakpoints, viewport, media queries, container queries, preferences, pointer, DPR, safe area | | Styles | fluid clamp, responsive grid, responsive stack | | Motion | spring physics, responsive transitions, reduce-motion | | A11y | touch targets, font sizing, line length, audit | | Images | srcset generation, art direction, optimal widths | | Debug | devtools overlay, perf marks, logging |

Architecture

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

Entry Points

| 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.

Recipes

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);
});

Ecosystem

fluidity-ts is built to slot into the rest of your design-system toolchain.

| Integration | What it gives you | | :--- | :--- | | Panda CSS plugin | Bring typed breakpoints and responsive primitives into recipe-driven styling workflows. | | Vanilla Extract integration | Use responsiveStyle() and fluid helpers inside .css.ts files without adding runtime coupling. | | Tailwind plugin | Mirror a single breakpoint source of truth into Tailwind screens with fluidity-ts/tailwind. | | Storybook addon | Inspect breakpoints, preferences, and viewport state while building and reviewing components. |

SSR Integration

// 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
});

Testing

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 Support

| 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.

Bundle Size

| 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.

API Reference

| 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 |

| 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 |

| 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 |

| 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 |

| 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 |

| 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 |

| 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 |

| Export | Type | Description | | :--- | :---: | :--- | | tailwindPreset(system) | fn | Tailwind preset that mirrors your breakpoints as screens |

Used By

Projects and design systems using fluidity-ts will be featured here soon. Add your project!

Star History

Star History Chart

Sponsors

Contributing

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.

License

MIT © Tamish Mhatre and fluidity-ts contributors.

If fluidity-ts saves you time, consider giving it a ⭐