---
title: "Browser support"
description: "The tested floor, and the per-feature contract for what happens in a browser that lacks a progressive enhancement."
url: "https://opsinjs.pensievelabs.org/start/browser-support"
source: "https://opsinjs.pensievelabs.org/start/browser-support.md"
section: "Start here"
kind: "guide"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["browser support", "supported browsers", "safari floor", "progressive enhancement"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="guide" />

## Overview [#overview]

opsinjs uses several CSS features that are not evenly supported, and it uses them
on purpose: the material ladder, the squircle geometry, wide-gamut colour and
spring-shaped easing are what make the product theme feel like a native health app
rather than a web dashboard. None of them is allowed to be load-bearing.

This page states two separate things, and they are separate on purpose:

* **The floor** is the oldest engine opsinjs is tested against, which is a policy
  decision the project makes and can be held to.
* **The enhancement contract** covers, for each optional feature, what it does,
  and precisely what a browser that lacks it gets instead. This is the part that
  matters, because it is the part a reader experiences.

<Callout title="The support matrix is not published yet">
  Sixty components are implemented and none of them has been tested against
  a browser matrix, so a per-feature version table on this page would be
  remembered rather than measured. The matrix below renders from generated data
  and stays empty until a test run produces some. The *contract* is a design
  decision, and it is stated in full. It sets out what degrades to what.
</Callout>

## The tested floor [#the-tested-floor]

**Safari and iOS Safari 16.4, and the current and previous major versions of
Chrome, Edge and Firefox.**

That is a commitment about testing, not a claim about behaviour: below the floor,
pages will very often still work, and we will not knowingly break them, but we do
not test and will not treat a defect there as a release blocker.

The floor is set by the reader, not by the developer. Consumer health apps are
read overwhelmingly on phones, and a meaningful share of those phones belong to
people who do not update them. Those are often the older and less well readers
whom a health product least wants to exclude. That argues for a conservative
floor. It also argues against making the floor so old that the system has to
abandon the visual language that makes it feel trustworthy, which is why
everything below the floor is handled by enhancement rather than by exclusion.

<BrowserSupport />

<Todo>
  Not one of the sixty components has been tested against any browser, at the floor
  or above it. The floor above is a commitment about what will be tested, and the
  table renders from generated data that does not exist yet. Until a run produces
  some, treat every row of the enhancement contract below as a design decision
  rather than an observation.
</Todo>

## The enhancement contract [#the-enhancement-contract]

Read this as a promise about degradation. Every row is implemented with an
`@supports` query or a media query, never with user-agent detection. `linear()`
easing is the one exception: no `@supports` guard is available for it, so it
relies instead on CSS's own rule for a value an engine cannot compute. Each
fallback is a designed state rather than an accident, and the one that is a bare
browser default says so below rather than dressing itself up as a curve we
chose.

### Backdrop blur and the material ladder [#backdrop-blur-and-the-material-ladder]

Six of the system's surface rungs are defined partly by a backdrop filter. Without
it, each rung falls back to an opaque surface whose colour is derived to hit the
same measured contrast against the same content beneath. The ladder stays ordered
and the floor is still met; what is lost is the sense of depth.

The same fallback path is used, deliberately, for `prefers-reduced-transparency`,
so the opaque route is exercised by real users on every engine rather than being
an untested branch. See
[Reduced transparency](../foundations/materials/reduced-transparency.mdx).

### Squircle geometry with `corner-shape` [#squircle-geometry-with-corner-shape]

The product theme uses continuous corner curvature rather than a circular radius,
because it is what the platforms these readers use every day look like. Where
`corner-shape` is unavailable, surfaces fall back to a plain `border-radius` at
the same token value. Nothing reflows, nothing changes size, and no information
depends on the difference. See [Shape](../foundations/shape/index.mdx).

### Wide-gamut colour in Display P3 [#wide-gamut-colour-in-display-p3]

Colours are authored in OKLCH and emitted with an sRGB fallback first and a
`color-gamut: p3` enhancement second. A display or engine without wide gamut gets
the sRGB value, which is not a compromise: the sRGB value is the one the contrast
floor is measured against, so conformance never depends on a wide-gamut screen.
The P3 version is a saturation improvement on hardware that can show it, and the
category and status hues stay distinguishable in both. See
[Colour and gamut](../foundations/colour/index.mdx).

### Spring easing as `linear()` [#spring-easing-as-linear]

Motion tokens are springs, exported as `linear()` easing functions with many
stops. Each spring is a single custom property, and there is no `cubic-bezier`
approximation behind it: a custom property holds `linear(…)` even where the
engine cannot compute it, so the cascade has nothing to fall back to. What
happens instead is that the declaration using it is `transition-timing-function:
var(--opsin-ease-spring-…)`, and it is invalid at computed-value time. The
property takes its initial value, and the move runs on the browser's default
easing. Durations are separate tokens and are identical either way, so nothing
in a sequence gets out of step and no transition is lost; what is lost is the
shape of the curve. The matrix above tests the feature in the browser you are
reading this in, which is the only honest answer here. The version table is not
published yet.

Under `prefers-reduced-motion: reduce`, both paths are replaced by the reduced
variant. That variant is not "no animation", but the specific degradation
documented per component. See
[Reduced motion](../accessibility/reduced-motion.mdx).

### Everything else [#everything-else]

Container queries, `:has()`, `@property`, subgrid and `text-wrap: balance` are used
where they help and never where their absence changes meaning. If one of them is
missing, a layout is slightly less refined; no value, status or control disappears.

## Verify it worked [#verify-it-worked]

The check that matters is not "does it look right in my browser". It is "does the
fallback path still meet the floor", and you can test that without an old device:

1. **Turn on Reduce Transparency** in your operating system's accessibility
   settings. Every material rung should become opaque and stay readable. This
   exercises the same code path as a browser with no backdrop filter.
2. **Turn on Reduce Motion.** Every transition should take its documented reduced
   form rather than simply vanishing.
3. **Force sRGB.** In a browser devtools rendering panel, emulate an sRGB display.
   Categories must remain distinguishable from one another and statuses from each
   other.
4. **Disable one feature at a time in devtools** rather than testing on an old
   phone. It is faster and it isolates the cause.

## Troubleshooting [#troubleshooting]

**A surface is opaque when you expected blur.** Check `prefers-reduced-transparency`
before you check the browser. It is the most common cause and it is working as
designed.

**Corners look circular rather than squircular.** Expected without `corner-shape`.
It is cosmetic, and no measurement, target size or contrast result depends on it.

**Colours look duller than the documentation.** You are on an sRGB display, or the
page is being rendered in an sRGB context. The sRGB values are the conformant
ones; the documentation is showing you the wide-gamut enhancement.

**Motion runs, but the springs feel generic.** `linear()` was not understood, so
the timing function was dropped at substitution and the browser's default easing
is running. There is no `cubic-bezier` approximation waiting behind it. Durations
and end states are preserved; only the shape of the curve differs.

**Something genuinely breaks below the floor.** Report it. We will not necessarily
fix it, and we will tell you which of those two it is rather than leaving the issue
open.

## Next [#next]

* [Framework support](./framework-support.mdx) is the other half of the support
  question: React version, server components and bundlers.
* [Materials](../foundations/materials/index.mdx) says what the ladder is and why
  the opaque fallback is a designed state rather than a compromise.
* [Springs as tokens](../foundations/motion/springs-as-tokens.mdx) is where the
  `linear()` values come from.
