Radix concepts in ShadowJS
The parts render with renderToString and hydrate onto the same nodes.
| Radix / shadcn idiom | ShadowJS |
|---|---|
asChild and Slot |
The as prop on every part that renders an element: a tag name (as="a") or a component (as={Button}). The part spreads its merged props (attributes, handlers, ref, children) onto the target. |
React.forwardRef |
props.ref is a callback ref. Wrappers forward it by spreading their props; parts combine it with their own through composeRefs. |
className |
class, merged with mergeElementProps: both sources are kept. |
| Controlled and uncontrolled | Pairs open/defaultOpen/onOpenChange, value/defaultValue/onValueChange, checked/defaultChecked/onCheckedChange, pressed/defaultPressed/onPressedChange. Controlled means the prop is not undefined. useControllableState builds the same contract. |
data-state, data-disabled, data-orientation, data-side, data-align |
The same attributes with the same values, for Tailwind variants such as data-[state=open]:animate-in. |
Portal with container |
Dialog.Portal, Popover.Portal, ... take container; the generic Portal from @shadow-js/web takes mount. Portaled content is not rendered on the server. |
forceMount and Presence |
forceMount on Content, Overlay, Portal; usePresence keeps content mounted while an exit animation runs, and unmounts at once when nothing animates. |
onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, onOpenAutoFocus, onCloseAutoFocus |
The same names on Content. Call event.preventDefault() to veto. |
Primitive.div |
Polymorphic |
useComposedRefs, composeEventHandlers |
composeRefs, composeEventHandlers |
useId |
useBaseId and partId; ids are equal on server and client |
Context per part (createContextScope) |
createPartContext("Dialog"): a missing root names itself in the error |
DirectionProvider |
DirectionProvider, useDirection |
VisuallyHidden |
VisuallyHidden |
Collection, RovingFocusGroup |
createCollection, useRovingFocus |
Server rendering#
The parts render with renderToString and hydrate onto the same nodes. Content inside a Portal is not rendered
in place on the server. With a portals collector (see the SSR guide, "Portals on the server") a dialog open at render
time is server-rendered as a block at the end of body; without one, as in this example, only the trigger and what is
outside the portal are rendered.
src/ssr.tsxtsx
import { Dialog } from "@shadow-js/primitives";
import { renderToString } from "@shadow-js/web/server";
const html = renderToString(() => (
<Dialog>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content>
<Dialog.Title>Hello</Dialog.Title>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
));
console.log(`trigger rendered: ${html.includes("aria-haspopup=\"dialog\"")}`);
console.log(`dialog content on the server: ${html.includes("Hello")}`);
console.log(`closed on the server: ${html.includes("aria-expanded=\"false\"")}`);
trigger rendered: true
dialog content on the server: false
closed on the server: true