One JSX tag for three kinds of custom element — arcgis-map, Calcite and your own Lit element in a React 19 app
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.
| Name | What it is | How 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 }:
| Element | Rendered markup | The 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 to | Result |
|---|---|---|
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.
| Chunk | Bytes | Why it is there |
|---|---|---|
calcite-dialog | 121,661 | Pulled in by the composition graph, not by anything the app renders |
calcite-panel | 19,719 | Same |
calcite-action-menu | 18,321 | Same |
calcite-action | 13,543 | Same |
calcite-loader | 7,798 | The button’s own loading state |
calcite-action-group,
calcite-icon, calcite-input-message,
calcite-scrim | 7,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.
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
- Register every custom element in the entry module. Side-effect imports at
the top of
main.tsx, before anything renders. A latecustomElements.define()loses non-primitive props permanently, with no error. - 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 }. - Get both event-casing rules, not one. Custom events:
lowercase
onplus the event name byte for byte, dashes included. Standard DOM events: React’s camelCase, soonClick. - Add the two reference directives, and a
declare modulefor your own element. The shipped typings are not auto-discovered, and the pre-19declare global { namespace JSX }shape no longer works. - Set
experimentalDecorators: trueanduseDefineForClassFields: falseif you author a Lit element — the flavor Lit recommends — andmoduleResolutiontobundlerornode16so the SDK’s subpaths resolve. With standard decorators instead, decorate anaccessor. - Do not expect non-primitive props to survive server rendering. Objects,
functions, symbols and
falseare omitted; they arrive at hydration. - 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
- Using the ArcGIS Maps SDK for JavaScript with React (the documented React 19 path, the install line, the entry-file registration instruction and the TSX reference directive)
- Components transition plan (the commitment to standards-based web components, and the 5.0 widget deprecation with its Q1 2027 removal target)
- Calcite Design System — using Calcite with frameworks (the 5.0.0 deprecation of the React wrapper, the pre-19 ref plus addEventListener pattern, and Calcite’s own reference directive)
- Calcite Design System — accessibility (focus containment and focusTrapDisabled, visible focus, forced-colors, prefers-reduced-motion, labelText and headingLevel)
- TypeScript setup for the Maps SDK (useDefineForClassFields for Accessor subclasses, and the moduleResolution requirement)
- React DOM components reference (the property-versus-attribute selection rule for custom elements, and the event casing rule)
- React 19 release notes (full custom element support, and the different client and server selection rules)
- React 19 upgrade guide (the removal of the global JSX namespace and the declare module form that replaces it)
- Lit — decorators (the recommendation of experimental decorators, and the accessor keyword under standard decorators)
- Lit — reactive properties (what a reactive property is, and the class-field trap behind the two tsconfig flags)
- ArcGIS API key authentication (what an embedded key does and does not protect, and the referrer-URL recommendation)
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