Skip to main content

Getting Started

Set up gentleduck/primitives with production defaults: accessibility, animation, direction, and state control.

Install

npm install @gentleduck/primitives
npm install @gentleduck/primitives

Requirements:

  • React 19+
  • TypeScript optional (types are bundled)

Set document direction once

If your product may support RTL later, set direction now.

import { DirectionProvider } from '@gentleduck/primitives/direction'
 
export function Providers({ children }: { children: React.ReactNode }) {
  return <DirectionProvider dir="ltr">{children}</DirectionProvider>
}
import { DirectionProvider } from '@gentleduck/primitives/direction'
 
export function Providers({ children }: { children: React.ReactNode }) {
  return <DirectionProvider dir="ltr">{children}</DirectionProvider>
}

Also set document semantics:

<html dir="ltr" lang="en">
<html dir="ltr" lang="en">

Use dir="rtl" at provider or primitive level where needed.


First production-ready Dialog

import * as React from 'react'
import * as Dialog from '@gentleduck/primitives/dialog'
 
export function DeleteProjectDialog() {
  const [open, setOpen] = React.useState(false)
  const [submitting, setSubmitting] = React.useState(false)
 
  async function onDelete() {
    try {
      setSubmitting(true)
      // await deleteProject()
      setOpen(false)
    } finally {
      setSubmitting(false)
    }
  }
 
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger className="rounded-md border px-3 py-2 text-sm">
        Delete project
      </Dialog.Trigger>
 
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out" />
 
        <Dialog.Content
          className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border bg-background p-5 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out"
          onEscapeKeyDown={(event) => {
            if (submitting) event.preventDefault()
          }}
          onInteractOutside={(event) => {
            if (submitting) event.preventDefault()
          }}
        >
          <Dialog.Title className="text-lg font-semibold">Delete project?</Dialog.Title>
          <Dialog.Description className="mt-1 text-sm text-muted-foreground">
            This action removes all environments and cannot be undone.
          </Dialog.Description>
 
          <div className="mt-4 flex justify-end gap-2">
            <Dialog.Close asChild>
              <button className="rounded-md border px-3 py-2 text-sm" disabled={submitting}>
                Cancel
              </button>
            </Dialog.Close>
            <button
              className="rounded-md bg-red-600 px-3 py-2 text-sm text-white"
              disabled={submitting}
              onClick={onDelete}
            >
              {submitting ? 'Deleting...' : 'Delete'}
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}
import * as React from 'react'
import * as Dialog from '@gentleduck/primitives/dialog'
 
export function DeleteProjectDialog() {
  const [open, setOpen] = React.useState(false)
  const [submitting, setSubmitting] = React.useState(false)
 
  async function onDelete() {
    try {
      setSubmitting(true)
      // await deleteProject()
      setOpen(false)
    } finally {
      setSubmitting(false)
    }
  }
 
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger className="rounded-md border px-3 py-2 text-sm">
        Delete project
      </Dialog.Trigger>
 
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out" />
 
        <Dialog.Content
          className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border bg-background p-5 shadow-xl data-[state=open]:animate-in data-[state=closed]:animate-out"
          onEscapeKeyDown={(event) => {
            if (submitting) event.preventDefault()
          }}
          onInteractOutside={(event) => {
            if (submitting) event.preventDefault()
          }}
        >
          <Dialog.Title className="text-lg font-semibold">Delete project?</Dialog.Title>
          <Dialog.Description className="mt-1 text-sm text-muted-foreground">
            This action removes all environments and cannot be undone.
          </Dialog.Description>
 
          <div className="mt-4 flex justify-end gap-2">
            <Dialog.Close asChild>
              <button className="rounded-md border px-3 py-2 text-sm" disabled={submitting}>
                Cancel
              </button>
            </Dialog.Close>
            <button
              className="rounded-md bg-red-600 px-3 py-2 text-sm text-white"
              disabled={submitting}
              onClick={onDelete}
            >
              {submitting ? 'Deleting...' : 'Delete'}
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

What this gives you immediately:

  • Focus trapping and restoration.
  • Escape + outside interaction dismissal control.
  • Correct dialog ARIA semantics.
  • State-driven animation hooks via data-state.

Import strategy

Use namespace imports to keep JSX readable and avoid naming collisions:

import * as Dialog from '@gentleduck/primitives/dialog'
import * as Popover from '@gentleduck/primitives/popover'
import * as Tooltip from '@gentleduck/primitives/tooltip'
import * as Dialog from '@gentleduck/primitives/dialog'
import * as Popover from '@gentleduck/primitives/popover'
import * as Tooltip from '@gentleduck/primitives/tooltip'

Controlled vs uncontrolled rule

  • Use uncontrolled (defaultOpen) for simple local UI.
  • Use controlled (open + onOpenChange) for workflows, async actions, analytics, or URL-driven state.

If you need business rules around close behavior, controlled mode should be your default.


Minimal QA before merge

  1. Keyboard-only: tab into trigger, open, tab cycle, close with Escape, confirm focus restore.
  2. Screen reader: confirm title and description are announced.
  3. Nested overlays: confirm outside click behavior is correct.
  4. Reduced motion: ensure animations are disabled when prefers-reduced-motion: reduce.
  5. RTL pass: verify placement/alignment for at least one floating primitive.

Next pages