Skip to content
Twilight React Playground
ثيم رائدaren

Drawer

componentBeginnerserverbrowserlive demo

A full-height panel on the side where the page starts reading (right in Arabic), opened by your state, for menus and filters.

import { Drawer, DrawerProps, DrawerHeaderProps, DrawerBodyProps, DrawerFooterProps } from '@salla.sa/twilight-theme-engine/components/drawer';

In plain words

A drawer is a panel that covers one side of the screen from top to bottom, like the menu behind the ☰ button on a phone. It works like Modal: your component keeps isOpen in state and passes onClose, which the drawer calls when the shopper clicks outside it or presses Escape.

You do not choose the side. On right-to-left pages (Arabic) it opens on the right, on left-to-right pages on the left: the side where reading starts.

Drawer.Header, Drawer.Body and Drawer.Footer are the sections inside. When the content is long only the body scrolls, and the header and footer stay where they are.

Signature

function Drawer(props: DrawerProps): React.ReactPortal | null

interface DrawerProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  size?: 'sm' | 'md' | 'lg' | 'full';  // default 'md' (w-80, 20rem)
  closeOnBackdropClick?: boolean;      // default true
  closeOnEscape?: boolean;             // default true
  preventBodyScroll?: boolean;         // default true: adds class menu-opened to <body>
  className?: string;                  // added to the panel
}
// No position prop: the side follows useTwilight().theme.is_rtl.

Drawer.Header(props: DrawerHeaderProps)  // <h2> title + close button
Drawer.Body(props: DrawerBodyProps)      // flex-1, scrolls
Drawer.Footer(props: DrawerFooterProps)

interface DrawerHeaderProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
  showCloseButton?: boolean;  // default true, but the button needs onClose
  onClose?: () => void;
}
interface DrawerBodyProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }
interface DrawerFooterProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode }

Try it live

A filters Drawer. Its side comes from the page direction, not from a prop: there is none.Try this: turn on long content: only the body scrolls, and the header and footer stay where they are.
Storefront canvas · ar · RTL
This page is right-to-left, so it opens on the right.
Controls
closeOnBackdropClick
closeOnEscape
preventBodyScroll
Drawer.Footer
Long content
What a theme writes
import { useCallback, useState } from 'react';
import { Drawer } from '@salla.sa/twilight-theme-engine/components/drawer';

export function FiltersDrawer() {
  const [isOpen, setIsOpen] = useState(false);
  const close = useCallback(() => setIsOpen(false), []);

  return (
    <>
      <button type="button" className="btn btn--primary" onClick={() => setIsOpen(true)}>
        Filters
      </button>
      <Drawer isOpen={isOpen} onClose={close}>
        <Drawer.Header onClose={close}>Filters</Drawer.Header>
        <Drawer.Body>{/* filter controls */}</Drawer.Body>
        <Drawer.Footer>
          <button type="button" className="btn btn--primary w-full" onClick={close}>
            Show results
          </button>
        </Drawer.Footer>
      </Drawer>
    </>
  );
}

Example

app/components/category/FiltersDrawer.tsx
import { useCallback, useState, type ReactNode } from 'react';
import { Drawer } from '@salla.sa/twilight-theme-engine/components/drawer';

export function FiltersDrawer({ children }: { children: ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  // Stable: Drawer re-runs its focus effect whenever onClose is a new function.
  const close = useCallback(() => setIsOpen(false), []);

  return (
    <>
      <button type="button" className="btn btn--outline-primary" onClick={() => setIsOpen(true)}>
        Filters
      </button>
      <Drawer isOpen={isOpen} onClose={close} size="md">
        <Drawer.Header onClose={close}>Filters</Drawer.Header>
        <Drawer.Body>{children}</Drawer.Body>
        <Drawer.Footer>
          <button type="button" className="btn btn--primary w-full" onClick={close}>
            Show results
          </button>
        </Drawer.Footer>
      </Drawer>
    </>
  );
}

How it behaves

  • The side is derived, not configured: useTwilight().theme.is_rtl gives right-0 when true and left-0 otherwise. The store settings answer is_rtl per request language, so Arabic pages open on the right.

  • It has the same lifecycle as Modal: renders nothing on the server or while closed, portals into document.body when open, focuses the panel, adds menu-opened to <body> and listens for Escape. There is no focus trap.

  • Unlike Modal, the Enter and Space handler sits on the backdrop, a sibling of the panel, so typing and pressing buttons inside a drawer is safe.

  • The panel is fixed top-0 bottom-0 flex flex-col and Drawer.Body is flex-1 overflow-y-auto, which is why the body alone scrolls. Sizes: sm w-64 (16rem), md w-80 (20rem), lg w-96 (24rem), full w-full. The wrapper is fixed inset-0 z-[200].

  • There is no slide-in animation: closed returns null, so the panel is always rendered at translate-x-0.

  • The engine uses it for the mobile menu (MobileMenu, with preventBodyScroll={false} because the menu locks scrolling itself) and the product-listing filters on small screens. The same export is also at @salla.sa/twilight-theme-engine/drawer.

Gotchas

  • There is no position prop. The example in the engine's drawer/index.ts passes position="left", which fails type checking and would do nothing. The side follows the page direction.

  • It calls useTwilight(), so rendering it outside TwilightProvider throws useTwilight must be used within a TwilightProvider (Modal, Collapse and Dropdown do not need the provider).

  • The effect code is the same as Modal's, so an inline onClose has the same cost: while open, every re-render of the owner moves focus back to the panel (an input inside loses focus as you type); while closed, every re-render removes menu-opened from <body> and refocuses the last opener. Wrap onClose in useCallback.

  • Scroll locking needs a theme CSS rule for body.menu-opened (the reference theme has one in app/styles/app.css); the engine only toggles the class.

Related

Source and docs