obs.js

repository·main·Indexed 20 days ago

https://github.com/csswizardry/obs.js

A context-aware web performance library that uses the Navigator and Battery APIs to detect connection strength and battery status. It provides raw Statuses and derived Stances via the window.obs object and CSS classes on the <html> element, allowing developers to adapt site experiences for users in constrained environments.

Tokens
5.6K
Snippets
6
Records
7
Agent score
22%

What's inside obs.js

  1. How Obs.js works: Statuses and Stances

    main

    Obs.js provides contextual information about a user's environment by categorizing data into two types:

    1. Statuses: Factual, raw data points (e.g., whether the battery is charging, if Data Saver is enabled, or the current network latency).
    2. Stances: Derived opinions based on multiple statuses. For example, if a user has a low battery or enabled Data Saver, Obs.js calculates a conservation preference of conserve.

    You can use both raw Statuses and derived Stances in your CSS (via classes on the <html> element) or in your JavaScript (via the window.obs object) to adapt your application's behavior.

  2. Analytics Installation (for data collection only)

    main

    Use Analytics mode if you only want to collect environmental signals for your own analytics endpoint and do not need to adapt the UI via CSS classes.

    In this mode, Obs.js will populate the window.obs object but will not add classes to the <html> element. This installation can be deferred or loaded as an external script.

    To use this mode, you must set adaptive: false in the window.obs config object before the script runs.

    <script>window.obs = { config: { adaptive: false } };</script>
    <script src="/path/to/obs.js" defer></script>
    <script src="/path/to/obs-speedcurve.js" defer></script>
  3. Handle unsupported browsers and Safari

    main

    Obs.js APIs are primarily available in Chromium-based browsers. Browsers like Safari (iOS) do not support these APIs, so you must decide whether to serve the 'rich' or 'lite' version of your site to these users. Obs.js does not make this decision for you; you must implement the logic using the window.obs properties.

    To decide based on whether the browser is slow/unsupportive: Use window.obs?.shouldAvoidRichMedia.

    To decide based on whether the browser is fast/supportive: Use window.obs?.canShowRichMedia.

    // Option A: Serve lite version to slow/unsupported browsers
    if (window.obs?.shouldAvoidRichMedia === true) {
      // Serve lite version to slow supportive browsers.
    } else {
      // Serve rich version to fast supportive browsers and Safari.
    }
    
    // Option B: Serve rich version only to fast/supportive browsers
    if (window.obs?.canShowRichMedia === true) {
      // Serve rich version to fast supportive browsers.
    } else {
      // Serve lite version to slow supportive browsers and Safari.
    }
  4. Adaptive Installation (for CSS/JS adaptation)

    main

    Use Adaptive mode if you want Obs.js to add CSS classes to your <html> element so you can adapt your UI via CSS or JavaScript.

    Requirements:

    • It MUST be placed in an inline <script> tag in the <head> of your document.
    • It must run before any other scripts, stylesheets, or HTML that depend on it.

    Copy and paste the following block as close to the top of your <head> as possible:

    <script>
      /*! Obs.js | (c) Harry Roberts, csswizardry.com | MIT */
    ;(()=>{const e=document.currentScript,t=window.obs,i=null!==t&&"object"==typeof t&&t.config||{},n=!1!==i.adaptive;if(n&&(!e||e.src||e.type&&"module"===e.type.toLowerCase())&&!1===/^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname))return void console.warn("[Obs.js] Skipping: must be an inline, classic <script> in <head>.",e?e.src:"type=module");const a=null!==t&&"object"==typeof t?t:{};window.obs=a;const r=!0===i.observeChanges,o=["low","medium","high"],l=["low","medium","high"],c=["very-low","low","medium","high"],d=["low","medium","high"],s=["strong","moderate","weak"],u=["strong","moderate","weak"],g=["conserve","neutral"],h=["rich","cautious","lite"];let y,m,v=!1;const w=(e,t,i)=>{n&&(t.forEach(t=>y.classList.remove(`has-${e}-${t}`)),null!==i&&y.classList.add(`has-${e}-${i}`))},b=(e,t)=>{n&&y.classList.toggle(e,t)},p=()=>{a.connectionCapability="low"===a.rttCategory&&"high"===a.downlinkCategory?"strong":"high"===a.rttCategory||"low"===a.downlinkCategory?"weak":"moderate";const e=!0===a.dataSaver||!0===a.batteryLow||!0===a.batteryCritical;a.conservationPreference=e?"conserve":"neutral",a.deliveryMode="weak"===a.connectionCapability||!0===a.dataSaver||!0===a.batteryCritical?"lite":"strong"!==a.connectionCapability||e?"cautious":"rich",a.canShowRichMedia="lite"!==a.deliveryMode,a.shouldAvoidRichMedia="lite"===a.deliveryMode,w("connection-capability",u,a.connectionCapability),w("conservation-preference",g,a.conservationPreference),w("delivery-mode",h,a.deliveryMode)},C=()=>{if(!m)return;const{saveData:e,rtt:t,downlink:i}=m;a.dataSaver=!!e,b("has-data-saver",a.dataSaver);const n=(e=>Number.isFinite(e)?25*Math.ceil(e/25):null)(t),r=(e=>Number.isFinite(e)?e<75?"low":e<275?"medium":"high":null)(t);null===n?(delete a.rttBucket,delete a.rttCategory):(a.rttBucket=n,a.rttCategory=r),w("latency",o,r);const c=(e=>Number.isFinite(e)?Math.ceil(e):null)(i),d=(e=>null===c?null:e<=5?"low":e>=8?"high":"medium")(c);null===c?(delete a.downlinkBucket,delete a.downlinkCategory):(a.downlinkBucket=c,a.downlinkCategory=d),w("bandwidth",l,d),"downlinkMax"in m&&(a.downlinkMax=m.downlinkMax),p()},k=()=>{v||(v=!0,y=document.documentElement,m=navigator.connection,C(),r&&m&&"function"==typeof m.addEventListener&&m.addEventListener("change",C),"getBattery"in navigator&&navigator.getBattery().then(e=>{const t=()=>(e=>{if(!e)return;const{level:t,charging:i}=e,r=Number.isFinite(t)?t<=.05:null,o=Number.isFinite(t)?t<=.2:null;a.batteryCritical=r,a.batteryLow=o,w("battery",["critical","low"],null),n&&o&&y.classList.add("has-battery-low"),n&&r&&y.classList.add("has-battery-critical"),a.batteryCharging=!!i,b("has-battery-charging",a.batteryCharging),p()})(e);t(),r&&"function"==typeof e.addEventListener&&(e.addEventListener("levelchange",t),e.addEventListener("chargingchange",t))}).catch(()=>{}),(()=>{if("deviceMemory"in navigator){const e=Number(navigator.deviceMemory),t=Number.isFinite(e)?e:null,i=(e=>Number.isFinite(e)?e<=1?"very-low":e<=2?"low":e<=4?"medium":"high":null)(t);a.ramBucket=t,null===i?delete a.ramCategory:a.ramCategory=i,w("ram",c,i)}if("hardwareConcurrency"in navigator){const e=Number(navigator.hardwareConcurrency),t=Number.isFinite(e)?e:null,i=(e=>Number.isFinite(e)?e<=2?"low":e<=5?"medium":"high":null)(t);a.cpuBucket=t,null===i?delete a.cpuCategory:a.cpuCategory=i,w("cpu",d,i)}})(),(()=>{const e=a.ramCategory,t=a.cpuCategory;let i="moderate";"medium"!==e&&"high"!==e||"high"!==t?("very-low"===e||"low"===e||"low"===t)&&(i="weak"):i="strong",a.deviceCapability=i,w("device-capability",s,i)})())};if(!0===document.prerendering){const e=()=>{document.removeEventListener("prerenderingchange",e),document.removeEventListener("visibilitychange",t),k()},t=()=>{"visible"===document.visibilityState&&e()};document.addEventListener("prerenderingchange",e,{once:!0}),document.addEventListener("visibilitychange",t)}else k()})();
    //# sourceURL=obs.inline.js
    </script>
  5. Enable change listening for SPAs

    main

    By default, Obs.js runs once per page load. For long-lived pages or Single Page Applications (SPAs), you can instruct Obs.js to listen for changes in connection strength or battery status by setting observeChanges: true in the configuration.

    This ensures the window.obs data and the <html> classes stay synchronized with the user's actual environment as it changes during their session.

    <script>window.obs = { config: { observeChanges: true } }</script>
    
    <script>
      // Obs.js
    </script>
  6. Access device and connection data via window.obs

    main

    Obs.js populates a global window.obs object with detailed properties representing the user's current environment. You can use these properties in JavaScript to make logic-based decisions (e.g., conditionally fetching high-resolution assets or adjusting application behavior).

    | Property | Type | Meaning | Computed/derived from |
    | :--- | :--- | :--- | :--- |
    | `config.adaptive` | boolean | Enable adaptive HTML classes | **Default `true`**; set by you _before_ Obs.js runs |
    | `config.observeChanges` | boolean | Attach change listeners | **Default `false`**; set by you _before_ Obs.js runs |
    | `dataSaver` | boolean | User enabled Data Saver | `navigator.connection.saveData` |
    | `rttBucket` | number (ms) | RTT bucketed to **ceil** 25ms | `navigator.connection.rtt` |
    | `rttCategory` | `'low'` \| `'medium'` \| `'high'` | CrUX tri-bin | Derived from RTT (`<75`, `>=75 and <275`, `>=275`) |
    | `downlinkBucket` | number (Mbps) | Downlink bucketed to **ceil** 1Mbps | `navigator.connection.downlink` |
    | `downlinkCategory` | `'low'` \| `'medium'` \| `'high'` | Bandwidth category | From `downlinkBucket` (≤ 5 → low, 6–7 → medium, ≥ 8 → high) |
    | `downlinkMax` | number (Mbps) | Max estimated downlink (if exposed) | `navigator.connection.downlinkMax` |
    | `connectionCapability` | `'strong'` \| `'moderate'` \| `'weak'` | Transport assessment | From `rttCategory` + `downlinkCategory` (low/high signals) |
    | `conservationPreference` | `'conserve'` \| `'neutral'` | Frugality signal | `dataSaver === true` or `batteryLow === true` |
    | `deliveryMode` | `'rich'` \| `'cautious'` \| `'lite'` | How ‘heavy’ you should go | From `connectionCapability`, `dataSaver`, `batteryLow`, `batteryCritical` |
    | `canShowRichMedia` | boolean | Convenience: `deliveryMode !== 'lite'` | Derived from `deliveryMode` |
    | `shouldAvoidRichMedia` | boolean | Convenience: `deliveryMode === 'lite'` | Derived from `deliveryMode` |
    | `batteryCritical` | boolean \| null | Battery ≤ 5% | Battery API |
    | `batteryLow` | boolean \| null | Battery ≤ 20% | Battery API |
    | `batteryCharging` | boolean \| null | On charge | Battery API |
    | `ramBucket` | number (GB) | Coarse device RAM bucket | `navigator.deviceMemory` (UA-rounded) |
    | `ramCategory` | `'very-low'` \| `'low'` \| `'medium'` \| `'high'` | RAM tier | From `ramBucket` |
    | `cpuBucket` | number (cores) | 1-core bucket (integer cores) | `navigator.hardwareConcurrency` |
    | `cpuCategory` | `'low'` \| `'medium'` \| `'high'` | CPU tier | From cores (≤ 2 = low, 3–5 = medium, ≥ 6 = high) |
    | `deviceCapability` | `'strong'` \| `'moderate'` \| `'weak'` | Device capability stance | From `ramCategory` and `cpuCategory` |
  7. Reference the available CSS classes added to <html>

    main

    Obs.js automatically adds specific CSS classes to the <html> element based on the user's device and connection state. You can use these classes in your CSS to apply adaptive styles (e.g., hiding heavy images or reducing animations) based on the user's current context.

    | Class | Meaning | Computed/derived from |
    | :--- | :--- | :--- |
    | `.has-data-saver` | User enabled Data Saver | `navigator.connection.saveData === true` |
    | `.has-battery-critical` | Battery ≤ 5% | `battery.level ≤ 0.05` |
    | `.has-battery-low` | Battery ≤ 20% | `battery.level ≤ 0.2` |
    | `.has-battery-charging` | On charge | `battery.charging === true` |
    | `.has-latency-low` | Low RTT | `rtt < 75ms` |
    | `.has-latency-medium` | Medium RTT | `>= 75ms` and `< 275ms` |
    | `.has-latency-high` | High RTT | `>= 275ms` |
    | `.has-bandwidth-low` | Low estimated bandwidth | `downlinkCategory === 'low'` |
    | `.has-bandwidth-medium` | Mid estimated bandwidth | `downlinkCategory === 'medium'` |
    | `.has-bandwidth-high` | High estimated bandwidth | `downlinkCategory === 'high'` |
    | `.has-connection-capability-weak` | Transport looks weak | `rttCategory === 'high'` or `downlinkCategory === 'low'` |
    | `.has-connection-capability-moderate` | Transport middling | Anything not strong/weak |
    | `.has-connection-capability-strong` | Transport looks strong | `rttCategory === 'low'` and `downlinkCategory === 'high'` |
    | `.has-conservation-preference-conserve` | Frugality signal present | `dataSaver === true` or `batteryLow === true` |
    | `.has-conservation-preference-neutral` | No frugality signal | Battery isn’t low and Data Saver is not enabled |
    | `.has-delivery-mode-lite` | Be frugal/lightweight | `connectionCapability === 'weak'` or `dataSaver === true` or `batteryCritical === true` |
    | `.has-delivery-mode-cautious` | Be careful/middle weight | Otherwise (not `rich`/`lite`) |
    | `.has-delivery-mode-rich` | Allow rich/heavy media | `connectionCapability === 'strong'` and `dataSaver !== true` and `batteryCritical !== true` |
    | `.has-ram-very-low` | Very low RAM tier | `ramCategory === 'very-low'` |
    | `.has-ram-low` | Low RAM tier | `ramCategory === 'low'` |
    | `.has-ram-medium` | Medium RAM tier | `ramCategory === 'medium'` |
    | `.has-ram-high` | High RAM tier | `ramCategory === 'high'` |
    | `.has-cpu-low` | Few logical cores | `cpuCategory === 'low'` (≤ 2 cores) |
    | `.has-cpu-medium` | Moderate logical cores | `cpuCategory === 'medium'` (3–5 cores) |
    | `.has-cpu-high` | Many logical cores | `cpuCategory === 'high'` (≥ 6 cores) |
    | `.has-device-capability-weak` | Hardware looks weak | `cpuCategory === 'low'` or `ramCategory` is `'very-low'`/`'low'` |
    | `.has-device-capability-moderate` | Hardware middling | Anything not strong/weak |
    | `.has-device-capability-strong` | Hardware looks strong | `cpuCategory === 'high'` and `ramCategory` is `'medium'` or `'high'` |