← Back to Blog

One JSX tag for three kinds of custom element — arcgis-map, Calcite and your own Lit element in a React 19 app

Three source cards on the left, a small map with zoom buttons, a stack of Calcite-style controls and a record row, each tagged with a code bracket, flow by blue arrows into a central registry column of three typed slots guarded by a clock and a shield check; a cube, a circle and a triangle then flow right into an app window showing a map with a pin, a control rail and a feature row, with a return arrow carrying an event back.

You are adding a map to a React 19 app. The design calls for an ArcGIS map with a zoom control, a column of Calcite buttons beside it, and one small status element of your own that neither library ships. Three different origins, three different release cadences — and in the markup they come out as the same thing: a lowercase, dashed tag in your JSX, with no wrapper package imported for any of them.

That really is how it works now, and it is worth being precise about why, because the one-line version of the story — React 19 passes objects to custom elements as properties — is not what React 19 does. It is close enough to sound settled, and wrong in the exact place that costs you an afternoon of staring at an element that renders and does nothing. This is the working version: what the three tags genuinely have in common, the four things the word “component” means in this stack, the three rules React 19 actually applies, and the parts of the job the no-wrapper story quietly leaves on your desk.

Every result below was checked against one specific set of versions: ArcGIS Maps SDK for JavaScript 5.1 (@arcgis/core@5.1.25, @arcgis/map-components@5.1.25), Calcite Design System 5.1 (@esri/calcite-components@5.1.2), React 19.3.0 with React 18.3.1 as the control, Lit 3.3.3, TypeScript 5.9.3 and Vite 8.3.1. Where an answer came from running the code rather than from reading a document, it says so.

Four things called a component

The vocabulary first, because there is a four-way collision in this subject and every ambiguous sentence about it costs a reader a re-read.

NameWhat it isHow you address it
React component A function that returns JSX. It exists only inside React’s tree. By importing the function. It has no tag name and no entry in the browser’s element registry.
Custom element (web component) A class registered with customElements.define(). A browser standard, independent of any framework. By its dashed tag name — arcgis-map, calcite-button, ats-badge.
Map components Esri’s package name for the SDK’s own custom elements, shipped as @arcgis/map-components. Same as any other custom element. The capital M is a product name, not a new mechanism.
SDK widget The pre-component @arcgis/core/widgets/* classes — Home, Legend, Search. Deprecated at 5.0. By constructing the class in JavaScript and adding it to a view. This is the thing the Map components replace.

An Experience Builder widget is a fifth thing with its own manifest and runtime, unrelated to all four, and it appears nowhere else in this post. From here on, this article says custom element for the browser standard, Map components for Esri’s package, React component for a function in your own tree, and widget only for the deprecated @arcgis/core/widgets/* classes. The bare word “component” is retired.

The three tags really are one kind of thing

This is not a figure of speech. Install the SDK the way Esri’s React guide documents it — npm install @arcgis/map-components @arcgis/core @esri/calcite-components — add lit for the element you are writing yourself, and then ask npm what it resolved, with npm ls lit @lit/react @arcgis/lumina --depth=3. The three branches that matter, excerpted from what it prints:

+-- @arcgis/map-components@5.1.25
| +-- @arcgis/common-components@5.1.25
| | +-- @arcgis/lumina@5.1.25 deduped
| | `-- lit@3.3.3 deduped
| +-- @arcgis/lumina@5.1.25
| | `-- lit@3.3.3 deduped
| `-- lit@3.3.3 deduped
+-- @esri/calcite-components@5.1.2
| +-- @arcgis/lumina@5.1.25 deduped
| `-- lit@3.3.3 deduped
`-- lit@3.3.3

Every other branch of that tree dedupes to the same lit@3.3.3. One physical copy of Lit, shared by Esri’s Map components, by Calcite, by Esri’s own Lumina build layer, and by the element you wrote: one custom-element base, shared. That is the substance behind Esri’s position on the direction of the SDK: “Esri is fully committed to building standards-based web components that extend the core API of the JavaScript Maps SDK into reusable custom HTML elements, such as <arcgis-map></arcgis-map>,” and “Today, the recommended approach for building web apps with the SDK is to use components.”

If you would rather not install anything, the 5.x CDN is a single module script: “This is the main entry point for adding the core API library, all component packages, and the CSS to your application. The CDN is available in MAJOR.MINOR format” — <script type="module" src="https://js.arcgis.com/5.1/"></script>. For a mapping app that one tag covers Calcite as well as the SDK.

The wrapper packages: two different statuses, and they are not interchangeable

Both libraries ship a React wrapper package. Only one of them is deprecated, and writing the other one off is the kind of confident sentence that ends up in a migration ticket.

Calcite’s wrapper is deprecated, on the record, with React 19 named as the reason. From the Calcite frameworks page: “Calcite components React is deprecated in version 5.0.0. With React 19+’s custom elements support, the calcite-components-react wrapper is no longer needed. Consider upgrading to React 19+ and using @esri/calcite-components directly.” The decision landed earlier than the deprecation — the Calcite 3.0 release summary from February 2025 says “React 19+ natively supports custom elements, so a wrapper is no longer required. Older React versions can still use the wrapper for smoother integration.” It is still published, at 5.1.2, and carries no npm deprecation flag.

The SDK’s wrapper is a different case. @arcgis/map-components-react is alive: version 5.1.25 on npm, no npm deprecation flag, prereleases still publishing daily (5.2.0-next.124 on 2026-09-28, when we checked), and a peer range of react: >=18.0.0 <20.0.0 — a range that still admits React 18, which is the population it exists for. Re-check that status before you bet on it. What it does not appear in is Esri’s documentation: the string map-components-react is absent from the React guide, the Map components reference, the 5.1 release notes and the FAQ. The accurate sentence is superseded in Esri’s documented React path, not deprecated. The documented path is the raw element: “Using the SDK with JSX in a React 19 project is similar to using it in a vanilla JavaScript and HTML project,” and the page’s only sample renders <arcgis-map> directly.

Rule 1: React 19 assigns a property only if the property is already there

Here is the load-bearing correction. React’s own reference is explicit, and it is a conditional statement in three parts: “By default, React will pass values bound in JSX as attributes” … “Non-string JavaScript values passed to custom elements will be serialized by default” … “React will, however, recognize a custom element’s property as one that it may pass arbitrary values to if the property name shows up on the class during construction.”

So the behavior depends on the element, not on React. To see the size of that dependency, render the same object into three elements in one pass: one that declares the accessors, one that is registered but declares nothing, and one that was never registered at all. On React 19.3.0, with the object { count: 7 }:

ElementRendered markupThe property, read back
declares detail <x-declared class="cn"> — no detail HTML attribute at all el.detail === obj is true. Object identity preserved, setter called once.
registered, declares nothing <x-bare detail="[object Object]" label="hi"> el.detail is undefined. The object was flattened into an HTML attribute.
never registered <x-notdefined detail="[object Object]" label="hi"> Same flattening. React had nothing to inspect.

Read the middle row again, because it is the one that breaks the slogan. For an element that does not declare the property, React 19 behaved exactly like React 18: the object became the string [object Object] in an HTML attribute, with no error and no console warning. React 19 does not pass non-primitive props to custom elements as properties. It passes them as properties when the property exists on the upgraded instance, and stringifies them into an HTML attribute when it does not.

For two of your three tags this is somebody else’s problem, already solved. arcgis-map and calcite-button are Lit elements, and Lit’s @property() decorator — the reactive property on a custom element class, not an Accessor-backed property on a core API class like MapView.center — defines its accessor on the prototype. The property name is there when React looks. For the element you write yourself, the property path opens only because you declared it:

// src/ats-badge.ts
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";

export interface BadgeDetail {
  label: string;
  count: number;
}

@customElement("ats-badge")
export class AtsBadge extends LitElement {
  static styles = css`
    :host { display: inline-flex; align-items: center; gap: 0.5rem; }
  `;

  // A primitive. React assigns this one as a property too, because the accessor exists.
  @property({ type: String })
  label = "";

  // An object. `attribute: false` keeps it off the HTML attribute surface entirely --
  // and because the accessor exists, React 19 assigns it as a property.
  @property({ attribute: false })
  detail: BadgeDetail | null = null;

  // The event main.tsx listens for. React 19 binds to it with `onats-badge-change` --
  // lowercase `on`, then the event name byte for byte.
  // Dispatch only. Writing `this.detail` here as well would give the element a second
  // source of truth that React never reconciles, because the prop it passes is unchanged.
  private announce(next: BadgeDetail): void {
    this.dispatchEvent(new CustomEvent<BadgeDetail>("ats-badge-change", {
      detail: next, bubbles: true, composed: true }));
  }

  // Lit's render() builds this element's template. It is not a layer's symbology.
  render() {
    const count = this.detail?.count ?? 0;
    return html`
      <span part="label">${this.label}</span>
      <button part="bump" aria-label=${`${this.label}: ${count}`}
              @click=${() => this.announce({ label: this.label, count: count + 1 })}>
        ${count}
      </button>`;
  }
}

The accessors are the whole of it. An element that declares no such property at all — no accessor, no field, nothing for React to find on the instance — still registers and still renders, and silently receives detail="[object Object]" instead, which is the middle row of the table above.

Rule 1, corollary: register in the entry module, or lose the object permanently

Since the property has to exist when React renders, registration order is load bearing. Esri’s React guide gives the instruction — “In your entry file (for example index.jsx), import the SDK components you want to use. This registers them with the browser’s CustomElementRegistry” — but not the consequence of ignoring it, so here it is, measured. Render an element before defining it, then define it and call customElements.upgrade() on the live node:

BEFORE define: outerHTML   : <x-late detail="[object Object]" label="hi"></x-late>
AFTER  define: outerHTML   : <x-late detail="[object Object]" label="hi"></x-late>

Byte for byte the same line before and after. Reading the property back after the upgrade returns null rather than the object, and the setter log is empty: the setter was never called. The upgrade does not recover it. React does not re-apply props to an element that grew new accessors after the fact, and the object is gone. There is no error and nothing in the console. The practical rule is short, and it cuts against an optimization you would otherwise reach for without thinking: put the registering imports in the entry module, so every tag is defined before React first renders it. The hazard is specific rather than general: it is an element React renders before the module that defines it has run. A heavy element pulled into a late chunk that React reaches first degrades to React-18 behavior silently.

// src/main.tsx -- the ENTRY module. Registration happens here, before React renders anything.
import { createRoot } from "react-dom/client";
import { useState } from "react";

// Side-effect imports. Each one calls customElements.define() for its own tag.
import "@arcgis/map-components/components/arcgis-map";
import "@arcgis/map-components/components/arcgis-zoom";
import "@esri/calcite-components/components/calcite-button";
import "./ats-badge";

import type { BadgeDetail } from "./ats-badge";

function App() {
  const [detail, setDetail] = useState<BadgeDetail>({ label: "idle", count: 0 });

  // The wrapper needs a height of its own: `html, body { height: 100% }` stops at body.
  return (
    <div className="shell">
      {/* A public web map item -- Esri's own sample item, used here with no key on the page.
          In your app the item is yours, and the id is configuration rather than a literal. */}
      <arcgis-map item-id="02b37471d5d84cacbebcccd785460e94">
        <arcgis-zoom slot="top-left" />
      </arcgis-map>

      {/* A STANDARD DOM event, so React's synthetic camelCase name is the correct one. */}
      <calcite-button
        kind="brand"
        onClick={() => setDetail((d) => ({ label: "counted", count: d.count + 1 }))}
      >
        Count a feature
      </calcite-button>

      {/* An object prop, assigned as a property because AtsBadge declares the accessor;
          and a CUSTOM event, so the handler name is lowercase `on` + the exact event name. */}
      <ats-badge
        label={detail.label}
        detail={detail}
        onats-badge-change={(event: CustomEvent<BadgeDetail>) => setDetail(event.detail)}
      ></ats-badge>
    </div>
  );
}

createRoot(document.getElementById("root") as HTMLElement).render(<App />);

One thing that entry module does not show, and that every first attempt trips over. The map element fills the box you give it and no more, so the page still needs the few lines of CSS that give html and body a height. Esri’s React guide has them, under Configure CSS; without them the element mounts correctly and you see nothing. Note that those two rules stop at body: every wrapper you put between it and the map needs a height of its own as well, or the map still has no box to fill. Esri’s own sample sidesteps this by returning <arcgis-map> as the root of the component.

Rule 2: there are two event-casing rules, and knowing only one produces broken code

For events the element dispatches itself, React is byte-exact and the docs say so: “Events are case-sensitive and support dashes (-). Preserve the casing of the event and include all dashes when listening for custom element’s events.” Dispatch x-fire, xFire and XFire at an element with all three handlers attached, and each handler fires for exactly its own name and for no other. onXFire did not catch xFire. So it is onarcgisViewReadyChange, not onArcgisViewReadyChange; it is oncalciteSliderInput, not onCalciteSliderInput; and Esri’s own React sample writes the lowercase form.

Then the exception, which is where the first afternoon goes. Standard DOM events are still React’s synthetic events, even on a custom element. Writing onclick on a calcite-button — reasoning, quite logically, from the rule above — does not fail silently; Calcite’s shipped React typings catch it, and tsc --noEmit says so (the type is elided here for width):

error TS2322: Type '{ children: string; kind: "brand"; onclick: ... }'
  is not assignable to type 'ReactCalciteButton'.
  Property 'onclick' does not exist on type 'ReactCalciteButton'. Did you mean 'onClick'?

One character changed, and tsc --noEmit went to zero diagnostics. The rule in full: custom events take lowercase on plus the event name verbatim; standard DOM events keep React’s camelCase synthetic names. Half of that rule, applied everywhere, breaks on the very first button.

One more thing React 19 did not remove: the ref. Handlers for events you can name at render time are covered. Anything that needs the element instance itself — calling an imperative method, waiting on a view, measuring, adding a graphic — still needs a ref or event.target. Esri’s React sample reaches the element through event.target, not through a prop.

Rule 3: className maps to class, and server rendering drops your objects

Two smaller behaviors, one pleasant and one worth a guard rail. The pleasant one: className on a custom element becomes the class HTML attribute, which is what you wanted and what React 18 refused to do — React 18.3.1 on identical input wrote classname="cn" on the client, an attribute that styles nothing.

The guard rail is server rendering, where React 19 applies a different selection rule: “props passed to a custom element will render as attributes if their type is a primitive value like string, number, or the value is true. Props with non-primitive types like object, symbol, function, or value false will be omitted.” Feeding nine props of assorted types through renderToStaticMarkup on React 19.3.0 produced this:

<my-element str="hello" num="42" boolTrue="" class="from-className"></my-element>

Five props are simply not there: the object, the function, the symbol, the false boolean and the event handler. React 18 emitted two of them — boolFalse="false", which as an HTML attribute is truthy, and obj="[object Object]" — and dropped the function, the symbol and the event handler just as React 19 does. Neither version is doing anything useful with an object at this point. The consequence to design around: a server-rendered or prerendered custom element receives its non-primitive configuration only at hydration, so if the markup has to be meaningful before JavaScript runs, a custom element is the wrong place to put that configuration.

What this replaced, and why React 18 code still looks like this

It is worth seeing the old shape once, because it explains why the wrapper packages were written, and because plenty of code still looks like this. React 18 did not merely serialize objects — the Calcite frameworks page states the other half: “React uses a synthetic event system, so the custom events emitted from the web components won’t work with JSX in React. For example, to update a value when the calcite-slider component changes, using the standard web components, you need to save a ref to the element and add an event listener.” Measured on React 18.3.1, all three on* handlers were passed as props and none of them fired, and on an element that did declare its accessor, the setter was never called at all. Hence:

// ScaleControl.jsx — the React 18 shape: a ref, an effect, an explicit listener,
// and explicit cleanup.
import { useEffect, useRef, useState } from "react";
import "@esri/calcite-components/components/calcite-slider";

function ScaleControl() {
  const host = useRef(null);
  const [level, setLevel] = useState(0);

  useEffect(() => {
    const el = host.current;
    if (!el) return;
    const onInput = (event) => setLevel(event.target.value);
    el.addEventListener("calciteSliderInput", onInput);
    return () => el.removeEventListener("calciteSliderInput", onInput);
  }, []);

  return (
    <div>
      <calcite-slider ref={host}></calcite-slider>
      <p>{level}</p>
    </div>
  );
}

Four lines of ceremony per event, and every one of them a place to forget the cleanup. In React 19 that becomes oncalciteSliderInput={handler}. That is the genuine win, and it is a win about events — the property behavior is the conditional thing from Rule 1.

TypeScript: one line each for theirs, a declare module for yours

The remaining compile-time work in a React 19 project is type declarations, and it splits cleanly. For the SDK and for Calcite, someone already did it. Esri: “If you already have TypeScript set up in your React 19 project and would like to use web components with TSX, you can do so with one line of code at the top of your main .tsx file or Vite’s vite-env.d.ts file” — that line being /// <reference types="@arcgis/map-components/types/react" />. Calcite documents its own equivalent, /// <reference types="@esri/calcite-components/types/react" />.

Those directives are not optional and not auto-discovered. Deleting both from an otherwise working project, with the packages still installed, produced five errors immediately: error TS2339: Property 'arcgis-map' does not exist on type 'JSX.IntrinsicElements' twice, once for arcgis-zoom, and twice for calcite-button.

For your own element there is no shipped typing, and this is where a pre-React-19 habit fails. The React 19 upgrade guide moved the JSX namespace: “A long-time request is to remove the global JSX namespace from our types in favor of React.JSX,” and “You’ll now need to wrap module augmentation of the JSX namespace in declare module "…".” Tested three ways under jsx: react-jsx with TypeScript 5.9.3 and @types/react@19.3.0: the old declare global { namespace JSX { … } } shape failed with TS2339, and both declare module "react/jsx-runtime" and declare module "react" type-checked clean. The docs tie the specifier to your JSX runtime, so react/jsx-runtime is the correct choice for react-jsx — but the other one is not broken, and it is worth knowing which is a rule and which is a convention.

The shipped typings settle the question by declaring all three runtimes. @arcgis/map-components/dist/types/react.d.ts contains declare module "react", declare module "react/jsx-runtime" and declare module "react/jsx-dev-runtime", each with the tag names inside an IntrinsicElements interface; Calcite’s file does the same. Copy that hedge if you publish your element for other people to consume. For one app, one runtime module is enough:

// vite-env.d.ts
/// <reference types="@arcgis/map-components/types/react" />
/// <reference types="@esri/calcite-components/types/react" />

import type { AtsBadge, BadgeDetail } from "./src/ats-badge";

declare module "react/jsx-runtime" {
  namespace JSX {
    interface IntrinsicElements {
      // The element CLASS is the element type -- that is what makes `ref` and
      // `event.target` typed instead of `any`.
      "ats-badge": React.DetailedHTMLProps<React.HTMLAttributes<AtsBadge>, AtsBadge> & {
        label?: string;
        detail?: BadgeDetail | null;
        "onats-badge-change"?: (event: CustomEvent<BadgeDetail>) => void;
      };
    }
  }
}

Delete that block and the element still compiles and still works at runtime; only the JSX rejects it, with the same TS2339 on the element’s tag name. Types are the whole remaining compile-time cost of the hand-written element, and it is a real one: the SDK and Calcite hand you a reference directive, and your own element hands you a maintenance surface.

Lit’s decorator flags are your build configuration

There is no separate build step for a Lit element in a Vite React app. One vite build compiled src/ats-badge.ts, decorators and all, together with the React app — 1.14 s, exit 0, and the emitted bundle contains the string ats-badge. No second toolchain, no tsc emit step, no bundler plugin.

That is true in the sense of no additional build step. The decorators are a TypeScript transform, which means two tsconfig.json flags are now part of your build. Lit is clear about which flavor it wants: its decorators page recommends that TypeScript developers use the experimental decorators for now, for better compiler output. That flavor asks for experimentalDecorators: true, and for useDefineForClassFields: false. The matrix, each row a real tsc --noEmit run:

tsconfig@property() applied toResult
experimentalDecorators: true, useDefineForClassFields: false a plain class field exit 0 — Lit’s recommended setup
experimentalDecorators: false, useDefineForClassFields: true a plain class field fails — error TS1240: Unable to resolve signature of property decorator when called as an expression, plus TS1270, once per property
experimentalDecorators: false, useDefineForClassFields: true the same fields with the accessor keyword exit 0 — exactly the fix Lit documents for standard decorators

Lit’s reason for the accessor keyword is worth keeping in mind rather than memorizing the error codes: “Lit decorators that work on class fields with experimental decorators — such as @property(), @state(), @query(), etc. — must be applied to accessors or auto-accessors with standard decorators,” because “standard decorators cannot change the kind of a class member.” lit.dev also warns that mismatching these flags can break reactivity at runtime rather than at compile time; we did not reproduce that case, so treat it as lit.dev’s statement and not as a measurement of ours.

One more setting, and a second reason for one you already have. The SDK asks for the same field flag for its own reasons — “When using the core API’s TypeScript decorators, such as when building Accessor subclasses or extending base layers, it may be necessary to set the useDefineForClassFields flag to false for backwards compatibility” — so one flag, two independent reasons, and a Vite React-TS scaffold that ships it true. And the same page asks for moduleResolution of node16 or bundler “if you want TypeScript to honor the exports field in a package’s package.json” — which is how the SDK’s Map components subpaths resolve by name.

What the three tags cost

The tooling needs no Esri credential, and that is worth stating plainly because it is easy to assume otherwise: installing @arcgis/core, @arcgis/map-components and @esri/calcite-components, type-checking the project and building it — all three steps completed with no credential of any kind present — no .env, no esriConfig.apiKey, exit 0 throughout.

The map is a different matter, and the asymmetry between the two libraries is the sentence to carry away. calcite-button costs nothing per transaction: it is a UI element you install or load from a CDN, and using it consumes no service. arcgis-map is a client for metered services: “To use the ArcGIS Maps SDK for JavaScript to access content, services, or an organization, you need an ArcGIS Location Platform account, ArcGIS Online account, or ArcGIS Enterprise account,” and “Access tokens are required to access secure ArcGIS resources, such as routing and places.” Basemap styles, static basemap tiles, geocoding, routing and elevation are all named ArcGIS Location Services, and an account is required to use them. An <arcgis-map> tag with no token and no public item renders nothing useful. We are not quoting per-request prices or free-tier sizes here. We could not verify Esri’s published figures from the environment this post was written in, and rates change. Read the current rates from Esri’s own pricing pages and from your Location Platform dashboard, and date-stamp whatever you budget against.

The security note is short and permanent: a key in a Vite bundle is public. Esri says as much: API keys are “typically embedded directly into client applications.” API key authentication “is the easiest type of authentication to use, but it is not sufficient to protect confidential information.” Referrer URLs are the mitigation, and “it is also recommended that you set referrer URLs in order to enhance the security of the token.” Recommended, not automatic. Two things we have learned the expensive way and keep written down. First: never put a wildcard referrer on a metered production key, because a shared deployment domain means anyone on that domain can spend your metered usage on your key. Second, and this one is our own field note rather than a documented behavior: we have had editing a key’s referrers regenerate the key (filed 2026-07-23), which breaks the deployed build until you redeploy. Metered work belongs behind a server-side proxy holding its own key; only client-keyed basemap and portal-layer rendering is referrer-gated.

The last cost is bytes, and this one surprised us. Adding one calcite-button import to the probe app pulled nine Calcite chunks and the icon webfont into the Vite build, because Calcite’s own elements compose internally. Read the table as what the build emitted into dist/ for the shipped 5.1.2 package: we measured build output, not the bytes a browser fetches on first paint.

ChunkBytesWhy it is there
calcite-dialog121,661Pulled in by the composition graph, not by anything the app renders
calcite-panel19,719Same
calcite-action-menu18,321Same
calcite-action13,543Same
calcite-loader7,798The button’s own loading state
calcite-action-group, calcite-icon, calcite-input-message, calcite-scrim7,324 / 4,181 / 3,165 / 1,989 Same graph
CalciteWebCoreIcons (woff2)14,980 The icon webfont

The build emitted 1,146 files in total and Vite warned that some chunks exceed 500 kB after minification. None of that is an argument against Calcite — it is an argument against assuming that “import only what you use” bounds the graph the way it sounds like it does. Measure the bundle after you add the first Calcite element, not after the twentieth.

The part the no-wrapper story leaves out: accessibility is not free

Consumption is identical for all three tags. Responsibility is not, and this is the largest unpriced item in the “they are all the same thing” framing.

Calcite ships a defined slice of accessibility as element behavior; the rest of its own guidance is author discipline. Its accessibility page commits to keyboard users not becoming trapped, in support of “Success Criterion 2.1.2: No Keyboard Trap,” with a deliberate exception: “The exception is with Dialog, where content is intended for focused interactions. Upon opening, the component’s focus will be contained within the component until dismissed or closed,” and non-modal dialogs can opt out through focusTrapDisabled. It commits to visible focus “In support of WCAG’s success criteria 2.4.7: Focus Visible and 2.4.13: Focus Appearance … for both mouse and keyboard users.” It supports the forced-colors media feature for Windows high contrast mode and prefers-reduced-motion for animation. It exposes labelText and label on most form elements, and headingLevel so a dialog’s heading does not break the page’s heading order. Theming comes with it: three mode classes — calcite-mode-light, calcite-mode-dark, calcite-mode-auto — and 71 distinct --calcite-color-* CSS custom properties, counted in the CDN bundle of the shipped 5.1.2 package, with light mode the default that needs no configuration.

Lit gives you none of that, and does not claim to — it is a rendering library. Your element starts with no focus management, no mode classes, no tokens, no dark mode and no labeling API. Worse, the shadow DOM that makes your element tidy works against you here: a <label for> outside a shadow root cannot reference an input inside one, which is why Calcite exposes labelText as element API rather than leaving it to markup. Every hand-written element is a small accessibility project, and the bill arrives whether or not you planned for it.

If you are not the one writing the code. One version and one date are all you need from this. Esri deprecated the SDK’s widget classes at version 5.0 and says “All widgets are planned for removal from the SDK as early as Q1 2027.” So any app still constructing Home, Legend or Search in JavaScript has a migration in front of it. The replacement is the custom elements described here. Separately, the map in that app is a client for metered services, and a key shipped in a browser bundle is public: the client-side control is a referrer restriction somebody has to set, it is recommended rather than automatic, and metered work belongs behind a server-side proxy holding its own key rather than resting on it. Finding out where you stand costs one conversation with whoever builds the front end: which SDK widgets are still constructed by hand, and whether the production key is referrer-restricted to your own domains.

What we do, and what it costs us

Being straight about our own practice: we do not use this pattern in our React apps, and that is a design decision rather than a technical objection. Across the 18 React brand apps in our portfolio, neither @arcgis/map-components nor @esri/calcite-components appears as a direct dependency, and every mapping app builds its view imperatively — import Map from "@arcgis/core/Map.js", import MapView from "@arcgis/core/views/MapView.js". Calcite chrome makes an app look like an Esri app, and those apps exist in order not to. One correction to our own in-code comments while we are here: Calcite is installed in those apps — it is a hard dependency of @arcgis/core. The accurate phrasing is “not a direct dependency and never authored against.” The ban keeps Calcite element JavaScript out of the bundle, verifiably; it does not keep the Calcite icon webfont out, which ships transitively at roughly 185 KB across its three formats.

The same house runs the other pattern deliberately. In our vanilla-TypeScript Esri showcase apps, Calcite is the correct chrome: 15 of 16 carry @esri/calcite-components as a direct dependency and nine of those 15 carry @arcgis/map-components too, with a full calcite-shell layout. Two stacks, two audiences, on purpose.

And the cost of abstaining, which is the part worth publishing: 18 of 18 React brand apps import at least one deprecated @arcgis/core/widgets/* class — with Home alone imported in 19 places. Esri deprecated all of them at 5.0 and plans removal “as early as Q1 2027.” Choosing the imperative path was free at the time and is not free now. That is not an argument that our brand apps should adopt custom elements tomorrow — it is a product decision and not this post’s call — but it is the honest version of a trade-off we made with our eyes open.

The obligations, in order

  1. Register every custom element in the entry module. Side-effect imports at the top of main.tsx, before anything renders. A late customElements.define() loses non-primitive props permanently, with no error.
  2. Declare the property on any element you write. React 19’s property path opens only for property names that exist on the upgraded instance. Lit’s @property() puts them there; for an object, add { attribute: false }.
  3. Get both event-casing rules, not one. Custom events: lowercase on plus the event name byte for byte, dashes included. Standard DOM events: React’s camelCase, so onClick.
  4. Add the two reference directives, and a declare module for your own element. The shipped typings are not auto-discovered, and the pre-19 declare global { namespace JSX } shape no longer works.
  5. Set experimentalDecorators: true and useDefineForClassFields: false if you author a Lit element — the flavor Lit recommends — and moduleResolution to bundler or node16 so the SDK’s subpaths resolve. With standard decorators instead, decorate an accessor.
  6. Do not expect non-primitive props to survive server rendering. Objects, functions, symbols and false are omitted; they arrive at hydration.
  7. Price the accessibility of anything you write yourself, and treat any key in the bundle as public.

None of that is difficult. All of it is invisible until it bites, and the one-line version of this story — no wrapper needed — is true precisely because somebody else’s element already declared its properties for you. Item 1, the registration, is still yours.

References

Standing up a React map app, or moving one off the deprecated widgets?

We build ArcGIS front ends in both stacks this post describes — React with the core API, and Calcite with the SDK’s custom elements — and we have done the widget migration inventory on our own fleet, so we know what it turns up. Book a free intro call and we will walk through what your codebase is carrying and what Q1 2027 means for it.

Book a free intro call