Every panel in our app is a Sheet, and the Dialog file the CLI wrote has zero importers
Nakodo has one modal shape. Editing a campaign's name, changing which campaign gets how many emails a day, reading a conversation, opening the menu on a phone: all of it slides in from the right. Nothing in the app opens
Nakodo has one modal shape. Editing a campaign's name, changing which campaign gets how many emails a day, reading a conversation, opening the menu on a phone: all of it slides in from the right. Nothing in the app opens in a centred box.
That was not a decision anybody wrote down. It became obvious when I counted the imports of the components in src/components/ui, the folder the shadcn CLI wrote and nothing else touches:
| file | lines | importers |
|---|---|---|
| sheet.tsx | 147 | 6 |
| alert-dialog.tsx | 199 | 3 |
| dialog.tsx | 168 | 0 |
| scroll-area.tsx | 54 | 0 |
| tabs.tsx | 89 | 0 |
| separator.tsx | 27 | 0 |
The three dialog-shaped files are a useful trio. The sheet is in constant use, the alert dialog is imported three times, and the plain dialog has never been imported at all.
A sheet is a dialog with different CSS
The first line of the file says so:
import { Dialog as SheetPrimitive } from "radix-ui"
There is no separate sheet primitive. Radix's Dialog provides the focus trap, the Escape handling, the aria-modal wiring, the portal and the overlay; the only difference between the two files the CLI generated is where the content is anchored and which direction it animates from. In the sheet that is a data-side attribute with four values and one long class string:
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover ... data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm ... data-[side=right]:data-open:slide-in-from-right-10",
className
)}
Keeping the one with the side variants and deleting nothing is why dialog.tsx is still there with no importers. Keeping the one we use and deleting the other would be the tidier end state, and the reason it has not happened is honest enough: nothing in the toolchain notices an unimported module. There is no CI in this repo, tsc does not complain about a file nobody imports, and the lint config has no rule for it.
Six importers, five jobs, two ways of being open
Five of the six sheets are state-driven and look the way you would expect. The campaign's name and logo editor, the sending settings, the "Manage campaigns" allocation panel: a boolean in a client component, <Sheet open={open} onOpenChange={setOpen}>, and a shared class so the three of them are the same width:
export const panelContentClass = "w-full gap-0 data-[side=right]:w-full data-[side=right]:sm:max-w-md";
The sixth is driven by the URL. A conversation on the outreach screen is a selected row, and selection is a search param, so the panel has no open state of its own to speak of. Closing it is a navigation:
export function ThreadSheet({ threadId, closeHref, children }: { threadId: string; closeHref: string; children: React.ReactNode }) {
const router = useRouter();
const [closed, setClosed] = useState<string | null>(null);
return (
<Sheet
open={closed !== threadId}
onOpenChange={(open) => {
if (open) return;
setClosed(threadId);
router.push(closeHref, { scroll: false });
}}
>
<SheetContent aria-describedby={undefined} className="w-full gap-0 p-0 data-[side=right]:w-full data-[side=right]:sm:max-w-xl">
{children}
</SheetContent>
</Sheet>
);
}
Three details in nine lines, each of which was a bug first.
open={closed !== threadId} rather than open={true}. The sheet is only rendered when a thread is selected, so a naive version is always open, and clicking the backdrop does nothing visible until the navigation finishes. Recording which id was dismissed lets the closing animation run while the router works, and because the state is the id rather than a boolean, selecting a different conversation opens the panel again without a reset.
{ scroll: false } on the push. The list behind the panel can be long. Returning to it at the top, after the reader had scrolled to the row they clicked, loses their place.
aria-describedby={undefined} on the content. Radix warns when a dialog has no Dialog.Description, which is good advice and wrong here: the description would duplicate the first line of the conversation. Passing undefined explicitly is the documented way to say there is no description, and it silences the console warning without inventing text for a screen reader to read out.
The accessible name of that same dialog is set in a different file. review.tsx owns the sheet, and page.tsx renders the thread's header inside it, so the SheetTitle sits around the creator's name on the header row:
<SheetTitle className="truncate text-base font-medium">
<Link href={`/campaigns/${campaignId}/creators/${thread.channelId}`} className="hover:underline">
{title}
</Link>
</SheetTitle>
SheetTitle is Radix's Dialog.Title under the new name, so the dialog's name is the creator's name, and it is also a link to their page. The mobile menu takes the opposite approach: it has a visible logo and no visible heading, so the title is there and hidden.
<SheetHeader>
<SheetTitle className="sr-only">Menu</SheetTitle>
<Logo href={null} />
</SheetHeader>
Every one of the six passes a title in one of those two ways, which is the one rule worth having about a dialog component: if it can be opened, it has a name.
The signed-out site has no modals at all
Our 76 public URLs are in the sitemap. I fetched every one of them and grepped the HTML for the data-slot attributes that the CLI-written components stamp on their roots:
| attribute | occurrences | pages |
|---|---|---|
data-slot="button" |
489 | 76 of 76 |
data-slot="tooltip..." |
3 | 1 |
data-slot="sheet..." |
0 | 0 |
data-slot="dialog..." |
0 | 0 |
data-slot="alert-dialog..." |
0 | 0 |
data-slot="dropdown-menu..." |
0 | 0 |
data-slot="table..." |
0 | 0 |
aria-modal |
0 | 0 |
So the marketing site uses two of the 24 primitives: buttons everywhere, and tooltips on the three annotated entries in the home page plate. No modal markup reaches a signed-out visitor, which is why none of this ships in the public JavaScript. Radix's Dialog is behind the sign-in boundary, and the pages a stranger reads, the guides and the pricing page, carry none of it.
You can check that claim yourself on any public page:
$ curl -s https://nakodo.app/pricing | grep -c 'data-slot="dialog'
0
$ curl -s https://nakodo.app/pricing | grep -o 'data-slot="button"' | wc -l
7
What it costs
The honest cost of one modal shape is that a sheet is the wrong shape for some things. A confirmation does not want 75% of the viewport sliding in from the right, which is why the only other dialog file with importers is alert-dialog.tsx. One of its three importers is the useConfirm hook that twelve questions in the app go through, and the other two are a plan change and a sending setting that each ask something before they do it.
The other cost is that the mobile menu, being a sheet of links rather than a nav of links, does not pass aria-current to anything inside it. The desktop sidebar does. That is on the list, along with deleting the four primitive files nobody imports: 338 lines of components the CLI wrote, that no screen renders, and that no tool in the repo will ever mention.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.