Published on

Triggering a Radix Dialog or ShadCN Dialog Without a Button

Authors
  • Name
    Ripal & Zalak
    Twitter

Introduction

Radix UI and ShadCN UI provide accessible dialog components with robust functionality. However, attempting to use a custom React component as a trigger instead of the default button can result in hydration errors in Next.js applications. This guide demonstrates how to achieve your desired behavior while addressing these errors.

The Problem

When using a custom component (e.g., a markdown editor) as a trigger for a ShadCN Dialog, the following errors often occur:

  • Hydration failed because the initial UI does not match what was rendered on the server.
  • Expected server HTML to contain a matching <button> in <div>.

This happens because Radix UI’s DialogTrigger expects a button element, which is essential for accessibility and consistency. Passing a non-standard element (like a React component) can cause mismatches between server-rendered and client-rendered HTML.

Solution

To resolve these issues, you need to:

  1. Ensure the custom component renders as a valid button element.
  2. Use DialogTrigger correctly with a controlled open state to manage the dialog.
  3. Handle interactions without breaking Radix UI’s accessibility features.

Step 1: Modify the Custom Component to Render as a Button

Ensure your custom component supports rendering as a button by adding an asChild prop to DialogTrigger. This allows you to use any valid React component as the trigger while maintaining accessibility and avoiding hydration issues.

Example:

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from 'shadcn-ui/dialog'
import React, { useState } from 'react'

const LLEditor = ({ onClick }: { onClick?: () => void }) => {
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      className="cursor-pointer rounded-lg border p-2"
    >
      Click to edit
    </div>
  )
}

const DialogExample = () => {
  const [open, setOpen] = useState(false)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <LLEditor onClick={() => setOpen(true)} />
      </DialogTrigger>

      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit Content</DialogTitle>
          <DialogDescription>Modify the content below:</DialogDescription>
        </DialogHeader>
        <div className="mt-4">Here’s your editable content.</div>
      </DialogContent>
    </Dialog>
  )
}

export default DialogExample

Step 2: Use Controlled State for the Dialog

Manage the dialog’s open state explicitly. This ensures the server-rendered and client-rendered HTML match:

  • Use the open prop of Dialog to control the visibility of the dialog.
  • Use setOpen to toggle the dialog state.

Step 3: Ensure Accessibility

To maintain accessibility, ensure that the custom trigger component:

  • Renders with a role="button".
  • Has a tabIndex={0} to allow keyboard navigation.
  • Supports onClick and onKeyDown handlers for keyboard accessibility.

Common Mistakes to Avoid

  1. Skipping the asChild Prop: Without asChild, Radix UI wraps your component in a button, causing hydration mismatches.
  2. Forgetting Accessibility Attributes: Custom components should include role, tabIndex, and ARIA attributes where necessary.
  3. Not Using Controlled State: Relying solely on DialogTrigger without open and onOpenChange can lead to inconsistent behavior.

Complete Example

Here’s a full example combining all the steps:

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from 'shadcn-ui/dialog'
import React, { useState } from 'react'

const LLEditor = ({ onClick }: { onClick?: () => void }) => {
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
      className="cursor-pointer rounded-lg border p-2"
    >
      Click to edit
    </div>
  )
}

const DialogExample = () => {
  const [open, setOpen] = useState(false)

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <LLEditor onClick={() => setOpen(true)} />
      </DialogTrigger>

      <DialogContent>
        <DialogHeader>
          <DialogTitle>Edit Content</DialogTitle>
          <DialogDescription>Modify the content below:</DialogDescription>
        </DialogHeader>
        <div className="mt-4">Here’s your editable content.</div>
      </DialogContent>
    </Dialog>
  )
}

export default DialogExample

Conclusion

Using a custom React component as a trigger for a Radix or ShadCN dialog is possible without hydration errors. By ensuring your component behaves like a button, using the asChild prop, and managing state explicitly, you can achieve the desired behavior while maintaining accessibility.