Get started
Eunomia is one library of ready-made pieces, buttons, inputs,
tabs, and more, built as standard
1. Install
npm install @eunomia/elements
That's the only thing to install.
2. Import a component
Every component is a named export of the package, and importing one is what makes its tag usable: it registers the element with the browser as a side effect of the import. A bundler only bundles the components you actually import, so your app never ships code for ones it doesn't render.
import {
EunomiaButton,
EunomiaTabGroup,
EunomiaTab,
EunomiaTabPanel,
} from "@eunomia/elements";
Importing EunomiaButton makes <eun-button> available. Tabs need
three tags working together as one system, <eun-tab-group>,
<eun-tab>, and <eun-tab-panel>, so import all three (see
This import registers the element with the browser as a side effect, which means it needs a real browser window to exist. In a framework that renders on the server (Next.js, Nuxt, SvelteKit, Angular Universal, or any other SSR setup), a plain top-level import evaluated during the server render throws ReferenceError: customElements is not defined, even for a Server Component that only imports a utility like initializeTheme from the same package. Keep every import behind your framework's client-only boundary (Next.js' "use client", Nuxt's <ClientOnly>/.client.ts, or the equivalent elsewhere) or inside a browser-only lifecycle hook (useEffect, onMounted, ...). The Next.js tab below shows the exact pattern; it carries over directly to any other SSR framework.
3. Use the tag
Pick a framework in the toolbar above and every snippet on this page adapts.
<eun-button variant="primary">Confirm</eun-button>
<eun-tab-group>
<eun-tab slot="navigation" panel="a">First</eun-tab>
<eun-tab slot="navigation" panel="b">Second</eun-tab>
<eun-tab-panel name="a">First panel.</eun-tab-panel>
<eun-tab-panel name="b">Second panel.</eun-tab-panel>
</eun-tab-group>
This is plain HTML, so it works with any framework, or with none at all.
<script type="module">
import { EunomiaButton } from "@eunomia/elements";
</script>
<eun-button variant="primary">Confirm</eun-button>
import { EunomiaButton } from "@eunomia/elements";
function App() {
return <eun-button variant="primary">Confirm</eun-button>;
}
React 19 renders custom elements natively, so properties and events
just work as you'd expect. On React 18 and earlier, pass properties
as plain lowercase attributes instead (disabled, not disabled={true}
bound as a JS property), and listen for custom events like
eunnavigate with addEventListener inside a ref callback rather
than an onClick-style prop.
"use client";
import { EunomiaButton } from "@eunomia/elements";
export function ConfirmButton() {
return <eun-button variant="primary">Confirm</eun-button>;
}
Import the component inside a client component, marked
"use client". Registering a custom element calls
customElements.define as soon as the file loads, and that needs a
real browser window to exist. A plain import at the top of a Server
Component runs during the server render, before any browser exists,
and throws.
<script setup>
import { EunomiaButton } from "@eunomia/elements";
</script>
<template>
<eun-button variant="primary">Confirm</eun-button>
</template>
Vue treats any tag containing a hyphen as a custom element
automatically, with nothing extra to configure. Bind a property with
:variant="..." and listen for a custom event with
@eunnavigate="...", exactly like any built-in DOM event.
Rendering with Nuxt instead of plain Vue? Nuxt renders on the server by
default, so this same import needs a client-only boundary: wrap the
import in <ClientOnly> or move it to a .client.ts plugin. See the
SSR warning above.
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import { EunomiaButton } from "@eunomia/elements";
@Component({
selector: "app-root",
template: `<eun-button variant="primary">Confirm</eun-button>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppComponent {}
Angular's template compiler rejects unfamiliar tags by default.
CUSTOM_ELEMENTS_SCHEMA tells it to allow any tag containing a
hyphen through unchecked. Add it once to every module (or every
standalone component) that uses a Eunomia tag.
Rendering with Angular Universal (SSR)? This same import needs a
client-only boundary: guard it with isPlatformBrowser(platformId)
(or move it into ngOnInit behind that same check) so it never runs
during the server render. See the SSR warning above.
4. Theme it
Every color, corner rounding, spacing, and shadow a component uses comes from a shared set of design values behind the scenes, never a color or size baked directly into the component. Skip this step entirely and components still render just fine, using sensible built-in defaults, the same ones shown throughout this documentation site.
To ship a real theme instead, include one of the 7 ready-made palettes:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/styles/themes/prebuilt-themes/violet/eunomia-violet.theme.scss"
/>
<body class="eunomia-theme eunomia-violet-theme">
<app-root></app-root>
</body>
The full picture, dark mode, all 7 palettes, and generating a theme
from your own two brand colors instead, is in
TypeScript
@eunomia/elements ships its own type declarations, so a class like
EunomiaButton and its properties are already typed the moment you
import it. Each component's module also augments the global
HTMLElementTagNameMap with its own tag as soon as it's imported, so
document.createElement("eun-button") is already typed correctly
too, the moment anything from the package has been imported anywhere
in your program: there's nothing to write for that yourself.
JSX and TSX
Writing <eun-button variant="primary"> directly in a .tsx file
needs one more step, since JSX reads its own separate
JSX.IntrinsicElements type rather than HTMLElementTagNameMap.
Import @eunomia/elements/jsx once, anywhere in your program:
import type {} from "@eunomia/elements/jsx";
It's a type-only import, so it carries no runtime cost and never
registers a custom element itself: a good place for it is right next
to your app's other global type declarations. Every eun-* tag then
becomes a typed JSX element, with the same property autocomplete and
invalid-value rejection as the component's own class.
Typed custom events
An event like eunnavigate stays a plain CustomEvent, since it's
built for addEventListener rather than a JSX onXxx prop. In
React, read it off a ref instead:
import { useEffect, useRef } from "react";
import { EunomiaButton } from "@eunomia/elements";
function ConfirmButton() {
const ref = useRef(null);
useEffect(() => {
const button = ref.current;
const handleNavigate = (event) => {
console.log(event.detail);
};
button?.addEventListener("eunnavigate", handleNavigate);
return () => button?.removeEventListener("eunnavigate", handleNavigate);
}, []);
return (
<eun-button ref={ref} variant="primary">
Confirm
</eun-button>
);
}
Each component's own page lists its full set of events (for example
detail.
Accessibility
Keyboard focus, screen reader announcements, and the right ARIA
attributes are already built into every component: nothing to add
yourself. Each component's own page has an Accessibility tab (for
example
Troubleshooting
Its file hasn't been imported yet, so the browser doesn't recognize the tag at all. Double-check that the import for that component actually ran: no typo in the path, not removed by tree-shaking, and not sitting behind a condition that never executes.
See the Angular tab above: add CUSTOM_ELEMENTS_SCHEMA. Other strict template compilers usually offer their own equivalent escape hatch for allowing custom elements through.
A component's file is being imported during a server render: a Next.js Server Component, a Nuxt page rendered without <ClientOnly>, a SvelteKit load function, an Angular Universal render, or any other SSR/static-generation pass. Move the import behind that framework's client-only boundary instead (see the SSR warning and the Next.js/Vue/Angular tabs above).
Confirm that data-brand and data-theme are set on <html> (this site's own convention, see eunomia-theme/eunomia-<name>-theme classes are present on <body> if you're loading the library's stylesheets directly instead. A component with no theme applied at all still renders fine, just with its built-in default colors, which can look like theming "isn't working" if you were expecting a different brand.
Check that you're actually setting data-theme="dark", rather than toggling a class of your own; see
Next steps
Browse everything available today
7 prebuilt themes, light and dark mode, or your own brand colors
Spacing, radius, shadows, typography, and color tokens
Why Lit, and how framework-agnostic components work