On a Centra fashion product page, the shopper picks a size, then expects the big purchase button to do the next honest step. When that size is out of stock, checkout is not available. A greyed-out add-to-cart leaves them stuck. I used to bolt a mail icon onto the unavailable chip, a second control they never look for. Fashion shoppers tap the primary button. I stopped decorating the size row and started deriving the whole purchase row from stock.
A notify-me flow is the replacement primary action: same button slot, mail icon, label "Notify me", opening a panel to capture email (and optionally confirm size) for a back-in-stock list. It is not a disabled cart. It is the main CTA when the selected variant cannot ship.
Purchase helpers now split cleanly. canAddToCart requires a selected size that is available. canNotifyForSize is true when a size is selected and unavailable. resolveSizeOptionState exposes unavailableSelected so chips stay tappable. resolvePrimaryPurchaseLabel returns Choose size, Notify me, or Add to cart. Icon follows: mail vs cart. Unavailable sizes remain selectable so notify can target the size the shopper actually picked.
In the demo below, pick out-of-stock size M. The primary button should flip to Notify me and open the fake panel. Toggle Bottom sheet vs Right drawer, then scroll inside the open panel: the email field stays in the scrollable middle so it does not hide under a sticky footer (that split is PanelBody in production, but here it is one scroll region).
Primary CTA from stock + shared Panel
Pick a size. Out-of-stock M flips the primary button to Notify me and opens a bottom or right Panel overlay.
No size yet. Primary stays Choose size.
Derive the row from stock
function resolvePrimaryPurchaseLabel(state: PurchaseUiState): string {
if (!state.selectedSize) return 'Choose size';
if (state.notifyMode) return 'Notify me';
return 'Add to cart';
}The notify overlay is a shadcn Sheet: right on desktop, bottom on mobile. Email field, optional size select inside. When the overlay opens, email and variant reset via a render-time prevOpen guard.
I had reset those fields in a useEffect. The previous email flashed for a frame because the effect fires one paint late. The prevOpen guard runs while rendering the newly opened state, so the fields are empty before paint. Submit currently closes the sheet. Backend wiring was out of scope for this slice.
Same flow on PDP, sticky cart, Quick Add, and product-card quick add. Copy the overlay and expect a waitlist email to send, and you ship a door that slams shut.
One Panel for every overlay
Then came the shared Panel molecule so every sheet stopped restyling header padding. Engine is 'sheet' | 'drawer'. Placement is 'responsive' (md+ right, else bottom) or a fixed side. Compound parts (Trigger, Content, Header, Body, Footer, Title, Description, Close) pick Drawer or Sheet primitives from context. PanelBody is the scroll region: min-h-0 overflow-y-auto. Size guide, notify overlay, cart, filters, mobile nav, and quick add all moved onto it.
Vaul Drawer and shadcn Sheet look interchangeable in a mock. In production they differ in focus trap, width tokens, and which side animates from the viewport edge. One Panel wrapper means I fix those once. A notify sheet that scrolls behind its footer is the same bug as a size guide that cannot reach Hip.
Sticky cart and quick add share the truth
Sticky cart and quick add are not special cases. They consume the same helpers. If PDP says Notify me and the sticky bar still says Add to cart, the shopper will tap the bar and try to buy an unavailable size. Product-card quick add gets the same path so a card on a PLP does not invent a third OOS treatment.
This slice models the CTA and the shell. I can wire the email later without restyling another drawer.
A greyed-out cart button looks honest until you realize notify was the only action left.
Happy coding! Sander