Trying the new <geolocation> HTML element in React

Jeff Huleatt

portrait of Jeff Huleatt

The Chrome team recently announced the new <geolocation> HTML element. It’s a standard HTML button that triggers the browser’s location permission prompt when clicked.

React doesn’t support it yet, but here’s a workaround to try it out anyway.

Creating a <geo-location> custom element

There’s a polyfill available for the <geolocation> element, but it returns early if your browser supports <geolocation>. This puts us in a bind for React, since React doesn’t support the <geolocation> element, but the polyfill just exits early and never creates the <geo-location> custom element if your browser does support it.

My workaround is to copy out the custom element code from the polyfill, minus the early exit.

Here’s my implementation if you’d like to copy and paste:

View full code
// Nothing to do if the element is natively supported
if ('HTMLGeolocationElement' in window) {
  return;
}

// Serialize Position for Forms
function serializePosition(pos) {
  if (!pos) return null;
  return {
    latitude: pos.coords.latitude,
    longitude: pos.coords.longitude,
  };
}

// Custom element
class GeoLocationElement extends HTMLElement {
  static get formAssociated() {
    return true;
  }
  static get observedAttributes() {
    return ['accuracymode', 'autolocate'];
  }

  constructor() {
    super();
    this._internals = this.attachInternals();
    this.attachShadow({ mode: 'open' });
    this._position = null;
    this._error = null;
    this._watchId = null;
  }

  // Attributes & Props
  get position() {
    return this._position;
  }
  get error() {
    return this._error;
  }

  get accuracymode() {
    return this.getAttribute('accuracymode') || 'approximate';
  }
  set accuracymode(val) {
    this.setAttribute('accuracymode', val);
  }

  get autolocate() {
    return this.hasAttribute('autolocate');
  }
  set autolocate(val) {
    val
      ? this.setAttribute('autolocate', '')
      : this.removeAttribute('autolocate');
  }

  get watch() {
    return this.hasAttribute('watch');
  }

  // Lifecycle
  connectedCallback() {
    this.render();
    this._handleInlineEvents(); // Bind onlocation="..."

    // Setup button listener
    this.shadowRoot.querySelector('button').addEventListener('click', (e) => {
      // Prevent form submission if the button is inside a form
      e.preventDefault();
      this.start();
    });

    if (this.autolocate) this._attemptAutolocate();
  }

  disconnectedCallback() {
    this._stop();
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal === newVal) return;
    if (name === 'accuracymode') this._updateButtonText();
    // If active and settings change, restart
    if (this._watchId || this._position) {
      this._stop();
      if (this.autolocate) this.start();
    }
  }

  // Rendering
  render() {
    if (this.shadowRoot.innerHTML.trim() !== '') return; // Don't re-render if exists

    const style = /* html */ `
      <style>
        :host { display: inline-block; font-family: system-ui, sans-serif; }
        button {
          padding: 8px 12px;
          cursor: pointer;
          background: #f0f0f0;
          border: 1px solid #ccc;
          border-radius: 4px;
          font-size: 0.9em;
          display: inline-flex;
          align-items: center;
          gap: 6px;
        }
        button:hover { background: #e0e0e0; }
        button:active { background: #d0d0d0; }
        .icon { width: 12px; height: 12px; background: currentColor; border-radius: 50%; opacity: 0.5; }
        .icon.active { color: #2ecc71; opacity: 1; }
        .icon.error { color: #e74c3c; opacity: 1; }
      </style>
    `;

    this.shadowRoot.innerHTML = /* html */ `
      ${style}
      <button type="button">
        <span class="icon"></span>
        <span class="text"></span>
      </button>
    `;
    this._updateButtonText();
  }

  _updateButtonText() {
    const btnText = this.shadowRoot.querySelector('.text');
    if (btnText) {
      const mode = this.accuracymode === 'precise' ? 'precise ' : '';
      btnText.textContent = `Use ${mode}location`;
    }
  }

  _setStatus(status) {
    const icon = this.shadowRoot.querySelector('.icon');
    icon.className = 'icon'; // reset
    if (status === 'success') icon.classList.add('active');
    if (status === 'error') icon.classList.add('error');
  }

  // Logic
  _handleInlineEvents() {
    const onLocationAttr = this.getAttribute('onlocation');
    if (onLocationAttr) {
      this.addEventListener('location', (event) => {
        try {
          new Function('event', onLocationAttr).call(this, event);
        } catch (e) {
          console.error('Handler error:', e);
        }
      });
    }
  }

  async _attemptAutolocate() {
    if (!navigator.permissions) return;
    try {
      const res = await navigator.permissions.query({ name: 'geolocation' });
      if (res.state === 'granted') this.start();
    } catch (e) {
      /* ignore */
    }
  }

  start() {
    if (!navigator.geolocation) return;
    const opts = { enableHighAccuracy: this.accuracymode === 'precise' };

    const success = (p) => {
      this._position = p;
      this._error = null;
      this._setStatus('success');
      this._updateForm(p);
      this._emit();
    };

    const fail = (e) => {
      this._error = e;
      this._position = null;
      this._setStatus('error');
      this._updateForm(null);
      this._emit();
    };

    if (this.watch) {
      if (this._watchId) navigator.geolocation.clearWatch(this._watchId);
      this._watchId = navigator.geolocation.watchPosition(
        success,
        fail,
        opts
      );
    } else {
      navigator.geolocation.getCurrentPosition(success, fail, opts);
    }
  }

  _stop() {
    if (this._watchId) {
      navigator.geolocation.clearWatch(this._watchId);
      this._watchId = null;
    }
  }

  _updateForm(data) {
    this._internals.setFormValue(
      data ? JSON.stringify(serializePosition(data)) : null
    );
  }

  _emit() {
    this.dispatchEvent(
      new Event('location', {
        bubbles: true,
      })
    );
  }
}

customElements.define('geo-location', GeoLocationElement);

With that, I have a <geo-location> custom element that can be called from a React component.

Using <geo-location> in a React component

import { useState } from 'react';
import './geolocationPolyfill';

export function GeoLocation() {
    const [location, setLocation] = useState(null);
    const [error, setError] = useState(null);

    const handleLocation = (event) => {
        if (event.target.position) {
            setLocation(event.target.position.coords);
            console.log("Location retrieved:", event.target.position);
        } else if (event.target.error) {
            setError(event.target.error.message);
            console.error("Error:", event.target.error.message);
        }
    }

    if (error) {
        return <p>{error}</p>
    } else if (location) {
        return <p>{JSON.stringify(location)}</p>
    }

    return (
        <geo-location
            accuracymode='approximate'
            onlocation={handleLocation}
        ></geo-location>
    );
}

Conclusion

It was fun to figure out a workaround for using the new <geolocation> element in React. I imagine the attributes will be camelCased if React adds official support, but otherwise this is a pretty good preview of the new element in React.