Building Interactive Drawers for PWAs and Mobile-First Applications

Introduction

In this guide, we'll explain implementation techniques for interactive drawers in Progressive Web Apps (PWAs) and mobile-first applications. I'll share insights from my journey finding the optimal solutions for enhancing user experience across different devices.

During my extensive work on PWA development, I've experimented with numerous drawer components and approaches. Creating a truly effective PWA comes with many challenges, and one significant hurdle was finding the right component to present users with choices or actions in an intuitive, mobile-friendly way that feels native.

The Challenge

After some research and testing multiple libraries, I chose shadcn/ui and its opinionated styling approach that leverages Radix UI under the hood. The Drawer component proved to be exactly what I needed. While I considered Ionic components as an alternative, I ultimately wanted complete control over the codebase.

Following shadcn's design philosophy provided the flexibility and performance I was looking for in a mobile-first component. In the following sections, I'll demonstrate both simple implementation methods for getting started quickly and more advanced approaches that enable significant reusability across your application.

Installation

First, you'll need to install the Drawer component from shadcn/ui. Run the following command in your project directory:

1npx shadcn-ui@latest add drawer

Basic Usage

Let's start with a simple example of how to implement a basic drawer component:

1<Drawer>
2  <DrawerTrigger asChild>
3    <Button variant="outline">Open Basic Drawer</Button>
4  </DrawerTrigger>
5    <DrawerContent className="max-h-[85vh] fixed inset-x-0 bottom-0 mt-24">
6      <div className="mx-auto w-full max-w-2xl">
7        <DrawerHeader>
8          <DrawerTitle>Basic Drawer Example</DrawerTitle>
9        </DrawerHeader>
10        <div className="p-4">
11          <p>This is a basic drawer example.</p>
12        </div>
13        <DrawerFooter>
14          <DrawerClose asChild>
15            <Button>Close</Button>
16          </DrawerClose>
17        </DrawerFooter>
18      </div>
19    </DrawerContent>
20  </Drawer>

Try it out: Basic Drawer Example

Shopping Cart Implementation

Now let's look at a more practical example demonstrating a shopping cart drawer - something you might actually use in a real e-commerce application:

1const initialItems: CartItem[] = [
2  { id: 1, name: "Product 1", price: 29.99 },
3  { id: 2, name: "Product 2", price: 39.99 },
4];
5
6export function ShoppingCartDrawer() {
7  const [cartItems] = useState<CartItem[]>(initialItems);
8
9  const total = cartItems.reduce((sum, item) => sum + item.price, 0);
10
11  return (
12    <Drawer>
13        <DrawerTrigger asChild>
14          <Button variant="outline">Open Cart ({cartItems.length})</Button>
15        </DrawerTrigger>
16        <DrawerContent className="max-h-[85vh] fixed inset-x-0 bottom-0 mt-24">
17          <div className="mx-auto w-full max-w-2xl">
18            <DrawerHeader>
19              <DrawerTitle>Shopping Cart</DrawerTitle>
20              <DrawerDescription>
21                Review your items before checkout
22              </DrawerDescription>
23            </DrawerHeader>
24            <div className="p-4 overflow-y-auto">
25              {cartItems.map((item) => (
26                <div
27                  key={item.id}
28                  className="flex justify-between items-center py-2"
29                >
30                  <span>{item.name}</span>
31                  <span>${item.price.toFixed(2)}</span>
32                </div>
33              ))}
34              <div className="mt-4 pt-4 border-t">
35                <div className="flex justify-between font-bold">
36                  <span>Total:</span>
37                  <span>${total.toFixed(2)}</span>
38                </div>
39              </div>
40            </div>
41            <DrawerFooter>
42              <Button className="w-full">Checkout</Button>
43              <DrawerClose asChild>
44                <Button variant="outline" className="w-full">
45                  Continue Shopping
46                </Button>
47              </DrawerClose>
48            </DrawerFooter>
49          </div>
50        </DrawerContent>
51      </Drawer>
52  );
53}

Shopping Cart Demo

Beyond Basics: Building a Reusable Drawer Component

Creating separate drawer components for different use cases would become inefficient and lead to significant code duplication as your application grows. To solve this problem, I've developed a generic, highly reusable drawer component that can adapt to different content types and selection behaviors while maintaining type safety.

The Generic Drawer Pattern

This implementation leverages TypeScript generics to create a flexible component that adapts its behavior and type definitions based on usage context. The <T extends boolean> generic parameter is particularly powerful:



This type-safe approach eliminates the need for separate components or runtime type checking, while providing excellent developer experience with proper autocompletion and error highlighting. Combined with the render props pattern for dynamic content, it creates a truly adaptable component.

1export interface ReusableDrawerItemComponentProps {
2  id: string;
3  onDelete?: (id: string) => void;
4  isSelected?: boolean;
5  onSelect?: () => void;
6}
7
8interface Props<
9  T extends boolean,
10  ItemProps = ReusableDrawerItemComponentProps,
11> {
12  renderTrigger: React.ReactNode;
13  title?: string;
14  options: Option[];
15  selectedValues: T extends true ? string[] : string;
16  onSelectionChange: (value: T extends true ? string[] : string) => void;
17  multiple?: T;
18  ItemComponent: React.ComponentType<ItemProps>;
19}

This component combines two powerful React patterns: render props and component injection. The renderTrigger prop uses the render props pattern to allow complete flexibility in how the drawer is opened. By accepting a React node, you can use any component (button, icon, text) to trigger the drawer.

Even more powerful is the component injection approach with ItemComponent. Instead of hardcoding how options are displayed, this pattern allows you to:

Notice how the ItemComponent receives standardized props (id, isSelected, onSelect) with all the necessary data and callbacks, while the drawer handles the state management logic. This makes the drawer incredibly versatile - the same component can display simple text options, complex cards with images, or interactive form elements just by swapping the ItemComponent.

1const ReusableDrawer = <T extends boolean>({
2  renderTrigger,
3  title,
4  options,
5  selectedValues,
6  onSelectionChange,
7  multiple = false as T,
8  ItemComponent,
9}: Props<T>) => {
10  const handleSelectionChange = (value: string) => {
11    if (!multiple) {
12      onSelectionChange(value as T extends true ? string[] : string);
13    } else {
14      const currentValues = selectedValues as string[];
15      const newValues = currentValues.includes(value)
16        ? currentValues.filter((v) => v !== value)
17        : [...currentValues, value];
18      onSelectionChange(newValues as T extends true ? string[] : string);
19    }
20  };
21
22  const isSelected = (value: string): boolean => {
23    if (!multiple) {
24      return selectedValues === value;
25    }
26    return (selectedValues as string[]).includes(value);
27  };
28
29  return (
30    <Drawer>
31      <DrawerTrigger asChild>{renderTrigger}</DrawerTrigger>
32      <DrawerContent className="h-3/4 overflow-hidden">
33        {title && (
34          <DrawerHeader>
35            <DrawerTitle>{title}</DrawerTitle>
36          </DrawerHeader>
37        )}
38        <div className="flex flex-col gap-2 p-4">
39          {options?.map((option) => (
40            <ItemComponent
41              key={option.value}
42              id={option.value}
43              isSelected={isSelected(option.value)}
44              onSelect={() => handleSelectionChange(option.value)}
45            />
46          ))}
47        </div>
48      </DrawerContent>
49    </Drawer>
50  );
51};
52
53export default ReusableDrawer

Custom Item Components

With our reusable drawer component in place, we can now create specialized item components that implement the ReusableDrawerItemComponentProps interface. Here's an example of a custom card component for age group selection that shows how easy it is to build on top of our foundation:

1export const SelectionCard: React.FC<RenderFunctionDrawerItemComponentProps & { label?: string }> = ({
2  id,
3  isSelected,
4  onSelect,
5  onDelete,
6  label,
7}) => {
8  // Optional auto-close when selection is made
9  const Wrapper = onSelect ? DrawerClose : "div";
10
11  return (
12    <div className="relative">
13      <Wrapper
14        onClick={onSelect}
15        className={cn(
16          "flex items-center justify-between rounded-lg border p-4 transition-all",
17          isSelected 
18            ? "border-blue-500 bg-blue-50 text-blue-700" 
19            : "border-gray-200 bg-gray-50 hover:bg-gray-100"
20        )}
21      >
22        <div className="flex flex-col">
23          <span className="font-medium">{label || id}</span>
24          <span className="text-sm text-gray-500">
25            {isSelected ? "Selected" : "Click to select"}
26          </span>
27        </div>
28
29        {isSelected && (
30          <div className="h-6 w-6 rounded-full bg-blue-500 text-white flex items-center justify-center">
3132          </div>
33        )}
34      </Wrapper>
35      
36      {onDelete && (
37        <button
38          onClick={(e) => {
39            e.stopPropagation();
40            onDelete(id);
41          }}
42          className="absolute -right-2 -top-2 h-6 w-6 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600"
43        >
44          x
45        </button>
46      )}
47    </div>
48  );
49};
50
51

Using the Reusable Drawer

Finally, here's a complete example showing how you would implement the drawer with your custom components in both single and multiple selection modes:

Single Selection Example

Selected: None

Multiple selection and more coming soon...

This pattern significantly reduces code duplication while maintaining flexibility and type safety. By separating the drawer's behavior from its content rendering, you can create a variety of drawer implementations with consistent behavior but customized appearances to match any design requirement.

Resources