shadcn/ui for Ember 6.12 LTS and 7

Every shadcn/ui component. Joined to Ember.

Every component and example on ui.shadcn.com, written as modern .gts components with yielded parts and Glint types, on a headless primitives layer. shadcn's own styles and theme tokens, compiled so Tailwind is optional. Accessible to WCAG 2.2 AA, with a test harness and an axe audit for every one.

pnpm add @ember-joinery/ember

Add @ember-joinery/test-support for the harnesses. Joinery publishes to npm with 1.0; until then, the guide has the steps.

Edit profile
Make changes to your profile here. Click save when you're done.

Why

shadcn's design, the Ember way#

Joinery ports shadcn/ui from its source, component by component and example by example, and compares every example's screenshots with shadcn's own, light and dark. What changes is the framework: React's props and asChild become Glimmer's named arguments, yielded parts and modifiers.

shadcn/ui, in React

<Dialog>
  <DialogTrigger asChild>
    <Button variant="outline">Open Dialog</Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Edit profile</DialogTitle>
    </DialogHeader>
    …
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">Cancel</Button>
      </DialogClose>
      <Button type="submit">Save changes</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

Joinery, in Ember

<Dialog as |d|>
  <Button @variant="outline" {{d.trigger}}>Open Dialog</Button>
  <d.Content>
    <d.Header>
      <d.Title>Edit profile</d.Title>
    </d.Header>
    …
    <d.Footer>
      <Button @variant="outline" {{d.close}}>Cancel</Button>
      <Button type="submit">Save changes</Button>
    </d.Footer>
  </d.Content>
</Dialog>

Accessible

WCAG 2.2 AA and the WAI-ARIA keyboard patterns, and every test ends in an axe audit. Where shadcn's markup falls short, Joinery's is fixed and the pixels stay shadcn's.

Parts you compose

A component yields its parts, typed with Glint. Render any part as your own element with @as, or put its modifier on a component of yours: <LinkTo {{d.trigger}}>.

Controlled or not

Leave state to the component, or own it, as in Radix: @open and @onOpenChange, @value and @onValueChange, with a @default… for each.

Headless primitives

@ember-joinery/primitives is the behaviour underneath, unstyled: focus, keyboard, layering with the Popover API, anchoring and ARIA, and everything ember-primitives offers.

Right to left

Logical properties, a direction from the nearest dir, arrow keys that flip, and every built-in string an argument with an English default.

Tailwind optional

shadcn's style sheets, compiled to plain CSS in a cascade layer of their own. Apps without Tailwind import two files; Tailwind apps can override any part with a class.

Accessibility

Accessible, and tested to prove it#

Joinery targets WCAG 2.2 AA. Every component has the roles, states, keyboard and focus management of its WAI-ARIA pattern, and every test ends in an axe audit. The harnesses read state through ARIA, as a screen reader does, so your own tests check the ARIA contract too.

Keyboard first

Each component follows its WAI-ARIA Authoring Practices pattern: arrows, Home and End, typeahead and Escape, each listed on its guide page and covered by its tests.

Focus that goes where it should

Dialogs, sheets, drawers and popovers move focus in, keep it there while modal, and give it back to their trigger. A modal makes the rest of the page inert.

An axe audit in every test

More than 1,600 axe audits run on every pull request: each component's tests, each example in the guide, and the guide's own pages.

Fixed where upstream falls short

Progress announces its value, a wide Table scrolls from the keyboard, the menus make the page inert rather than aria-hidden, and Data Table sorts with aria-sort. The pixels stay shadcn's.

Accessibility in the guide →

Install

Vite or classic, Tailwind or not#

Joinery's packages are v2 addons for Ember 6.12 LTS and 7. Vite (Embroider) apps import them directly, and classic ember-cli apps through ember-auto-import 2. Add the components and the harnesses, then import the theme's tokens and the components' CSS once.

pnpm add @ember-joinery/ember
pnpm add --save-dev @ember-joinery/test-support
// app/app.ts
import '@ember-joinery/ember/tokens.css'
import '@ember-joinery/ember/joinery.css'

In a Tailwind app, put Joinery's layer between Tailwind's base and components, and add tailwind.css, which maps the tokens to Tailwind's theme (bg-primary, rounded-lg) and adds the dark: variant:

@layer theme, base, joinery, components, utilities;
@import 'tailwindcss';
@import '@ember-joinery/ember/tailwind.css';

The heavier components come in packages of their own, each around the one library it needs, so an app installs only what it uses:

Package What it holds
@ember-joinery/ember The components, styled: joinery.css (the Nova style) and shadcn's seven other styles, tokens.css and the base colours, tailwind.css, and <Icon> for lucide's icons.
@ember-joinery/primitives The headless layer: every component's behaviour, unstyled, and ember-primitives' utilities, one subpath each (/color-scheme, /qp, /viewport…).
@ember-joinery/test-support A harness for every primitive and component, and the data-test-jn-* selectors they use.
@ember-joinery/chart Charts (area, bar, line, pie, radar and radial) as Glimmer SVG on d3-scale and d3-shape, with an API shaped like recharts.
@ember-joinery/carousel Carousel, on Embla Carousel: slides with motion, swipe and keyboard control.
@ember-joinery/data-table Data Table: sorting, filtering, pagination, row selection and column visibility, on TanStack Table's core.
@ember-joinery/code-block Code Block: code, terminal output and diffs, with line numbers, a copy button and Shiki's highlighting.
@ember-joinery/formwright Forms on Formwright: Joinery's inputs as Formwright controls, in shadcn's Field anatomy, with field harnesses.
@ember-joinery/file-upload File uploads, on ember-file-upload: a drop zone, a file list and an upload queue with progress.
@ember-joinery/tiptap A rich-text editor, on Tiptap: a toolbar of toggles, a heading menu and tooltips, stored as HTML or JSON.
@ember-joinery/power-calendar Calendars and date pickers, on ember-power-calendar: a day, a range or several days, inline or in a popover.
@ember-joinery/maskito Masked inputs, on Maskito, with number, date and time presets, and a phone input with a country picker that stores E.164.

The installation guide has the steps for each kind of app, and the repository's examples are one of each, built and tested in CI.

Usage

Strict mode, named args, typed parts#

Import what you use into a .gts file. A compound component yields its parts, each as a component and as a modifier, already wired to it: ids, ARIA, focus and state.

import { on } from '@ember/modifier'
import { Button, Dialog, Field, FieldGroup, Input, Label } from '@ember-joinery/ember'

<template>
  <Dialog @name="edit-profile" as |d|>
    <Button @variant="outline" {{d.trigger}}>Edit profile</Button>
    <d.Content>
      <d.Header>
        <d.Title>Edit profile</d.Title>
        <d.Description>Make changes to your profile here.</d.Description>
      </d.Header>
      <FieldGroup>
        <Field>
          <Label for="name">Name</Label>
          <Input id="name" @name="name" @value={{@user.name}} @onValueChange={{@rename}} />
        </Field>
      </FieldGroup>
      <d.Footer>
        <Button @variant="outline" {{d.close}}>Cancel</Button>
        <Button @name="save" {{d.close}} {{on "click" @save}}>Save changes</Button>
      </d.Footer>
    </d.Content>
  </Dialog>
</template>

Your own element: @as, or a part's modifier

<Button @as="a" href="/pricing">Pricing</Button>
<LinkTo @route="login" {{asButton variant="outline"}}>
  Log in
</LinkTo>

Controlled, when the app owns the state

<Dialog @open={{this.isEditing}}
  @onOpenChange={{this.setEditing}} as |d|>
  …
</Dialog>

Theming

shadcn's styles and colours, so any shadcn theme pastes in#

The tokens are shadcn's, in OKLCH (--background, --primary, --ring, --radius, --chart-1 to --chart-5…), and dark mode is a .dark class. All eight of shadcn's styles ship, Nova by default, and all seven base colours. This page is styled with them: pick a style and a base colour, and the whole page changes.

Theme this page

The same choices as every example in the guide.

In your app

The two imports that give an app this page's look.

import '@ember-joinery/ember/tokens.css'
import '@ember-joinery/ember/joinery.css'
Badge Secondary Outline ⌘K

A theme from ui.shadcn.com pastes in unchanged: its :root and .dark blocks go in your own CSS, after tokens.css, and win, because Joinery's rules sit in the joinery cascade layer. See Theming, or make one in the guide's Theme Builder: pick a style and a base colour, edit any token, see every component follow, and copy the CSS.

Components

All of ui.shadcn.com, and the rest of ember-primitives#

Every component on shadcn's docs pages, every example on them, the charts gallery and a set of its blocks. Each picture here is a guide example, as the guide renders it.

Testing

A harness for every component#

@ember-joinery/test-support has a harness class for every primitive and component. It finds its component by stable data-test-jn-* attributes, drives it the way a user does, with clicks, keys and typing, and reads its state through ARIA, so every test checks the ARIA contract too. Joinery's own tests are written with them, and every example in the guide shows its test.

import {
  ButtonHarness, DialogHarness, InputHarness, child,
} from '@ember-joinery/test-support'

// A page object: the dialog, its field and its button.
export class EditProfile extends DialogHarness {
  static named = 'edit-profile'

  @child(InputHarness, 'name') declare name: InputHarness
  @child(ButtonHarness, 'save') declare save: ButtonHarness
}
test('renaming', async function (assert) {
  await render(<template><Profile /></template>)
  const dialog = new EditProfile()

  await dialog.open()
  assert.strictEqual(dialog.label, 'Edit profile')
  await dialog.name.fillIn('Ada')
  await dialog.save.click()

  assert.false(dialog.isOpen)
  assert.dom(dialog.trigger).isFocused()
  await a11yAudit()
})
Attribute On
data-test-jn="dialog" The root of a component, which its harness is scoped to
data-test-jn-name="edit-profile" The same root, given @name, to tell it from others
data-test-jn-part="trigger" A part: a trigger, content, an item, an option…
data-test-jn-owner A part in the top layer, or portalled: the root it belongs to, which its harness follows
data-test-jn-value An option, item, tab or toggle, by its key

The attributes render in production, as Formwright's do, and are public API. Each component's guide page lists its harness's members under Testing (see Dialog's), and the guide's Testing page covers page objects, the helpers and the base class, for writing harnesses of your own.

Forms

Formwright, built in#

shadcn's forms are built on react-hook-form. Joinery's are built on Formwright, through @ember-joinery/formwright: Joinery's inputs as Formwright controls, a field layout in shadcn's Field anatomy, and field harnesses on Formwright's own. Call setupJoineryForms() once, and a field names its control.

<Form @model={{@profile}} @validator={{schema}} @onSubmit={{@save}} as |f|>
  <FieldGroup>
    <f.Field @for="name" @label="Name" @hint="Your public display name." />
    <f.Field @for="language" @control="select" @label="Language" @options={{@languages}} />
    <f.Field @for="newsletter" @control="switch" @label="Send me the newsletter" />
  </FieldGroup>
  <f.Submit {{asButton}}>Save</f.Submit>
</Form>

Formwright reads and writes the model, validates it with any Standard Schema, and keeps the labels, hints, errors and aria-invalid in step; Joinery draws it. The Formwright guide rebuilds shadcn's form examples on it, one for each form (shadcn writes most of them once for each form library).

Demo

The live demo#

shadcn's dashboard block, built with Joinery from the repository's test app: an inset sidebar, section cards, an interactive chart, and a data table to select, page through and reorder by dragging. The sidebar's Settings opens the settings block, a dialog with a section per link. Open it on its own, or see each block, and its code and tests, in the guide's Blocks.

Guide

Every component, every example, tested#

The guide has a page for each component, laid out like its ui.shadcn.com page, with every upstream example ported, live, and tested with its harness. Each example switches style, base colour, radius, light and dark, and right to left, shows itself at a phone's or a tablet's width, and has Template and Test tabs to copy from. A theme builder shows every component in the theme you make, and the API reference is generated from each package's types.

The guide's frame around the Card page's login card: Preview, Template and Test tabs, and switchers for the style, base colour, radius, dark mode, right to left and width. The guide's frame around the Card page's login card: Preview, Template and Test tabs, and switchers for the style, base colour, radius, dark mode, right to left and width.

Siblings

More for Ember#

Joinery follows the methods of its siblings, and works with them.

Formwright

Declarative, accessible forms for Ember: any validator, any data layer, and harnesses that read like your users. Joinery's forms are built on it.

real-enums

Enums as classes for TypeScript, with members, labels and options, which a select or radio group can take as its choices.

harnessed

Test harnesses for components and pages: write one class and it drives them in jsdom, Playwright, Cypress, Ember and more.