Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Collapsible sidebar

This recipe lets the user collapse the channel list and bring it back. A button in the channel list's header collapses it, and a button in the channel header shows it again. On a phone, the list opens as an overlay over the channel and closes when the user picks a channel.

The app owns the open or closed state, and CSS does the rest. The SDK gives the buttons a place:

  • HeaderEndContent, an override key, renders at the end of the channel list's header, and of the thread list's, while a channel or a thread is open.
  • StartContent, a prop of ChannelHeader, renders at its start. It replaces the back button, so the recipe renders that button too.

Below 768px wide, the SDK's stylesheet hides the channel list, and the app shows it, for example as an overlay. That's the mobile part of this recipe. Responsive layouts covers the rest of a layout on small screens.

Best practices

  • Keep the sidebar state in the app, in a React context, so the buttons and the layout read the same value.
  • Use one toggle component in both places. It collapses the list when it's open, and shows it when it's collapsed.
  • Drive the layout with a class on an element around ChatView. The channel list's root, .str-chat__channel-list, already animates its width, opacity and position.
  • Hide the button in the channel header while the list is visible, so only one of the two shows.
  • Close the list when a channel opens on a phone, where the list covers the channel.
  • To keep the user's choice across reloads, save sidebarOpen in localStorage.

Create sidebar state

src/SidebarContext.tsx
import { createContext, useCallback, useContext, useState } from "react";
import type { PropsWithChildren } from "react";

type SidebarContextValue = {
  closeSidebar: () => void;
  openSidebar: () => void;
  sidebarOpen: boolean;
};

const SidebarContext = createContext<SidebarContextValue | undefined>(
  undefined,
);

export const useSidebar = () => {
  const value = useContext(SidebarContext);
  if (!value)
    throw new Error("useSidebar must be used within a SidebarProvider");
  return value;
};

export const SidebarProvider = ({
  children,
  initialOpen = true,
}: PropsWithChildren<{ initialOpen?: boolean }>) => {
  const [sidebarOpen, setSidebarOpen] = useState(initialOpen);

  const closeSidebar = useCallback(() => setSidebarOpen(false), []);
  const openSidebar = useCallback(() => setSidebarOpen(true), []);

  return (
    <SidebarContext.Provider value={{ closeSidebar, openSidebar, sidebarOpen }}>
      {children}
    </SidebarContext.Provider>
  );
};

Create the toggle component

The toggle reads the state and flips it. Button is the SDK's button, so the toggle looks like the header's other buttons:

src/SidebarToggle.tsx
import { Button } from "stream-chat-react";
import { useSidebar } from "./SidebarContext";

export const SidebarToggle = () => {
  const { closeSidebar, openSidebar, sidebarOpen } = useSidebar();

  return (
    <Button
      appearance="ghost"
      aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
      circular
      className="sidebar-toggle"
      onClick={sidebarOpen ? closeSidebar : openSidebar}
      size="md"
      variant="secondary"
    >
      ☰
    </Button>
  );
};

Wire it into the app

Register the toggle as HeaderEndContent, and pass it to ChannelHeader as part of its StartContent, before the back button. A wrapper around ChatView gets the class that says whether the list is collapsed:

src/App.tsx
import {
  ChannelHeader,
  ChannelNavigation,
  Chat,
  MessageComposer,
  MessageList,
  ThreadHeader,
  WithComponents,
  WorkspaceNavigationBackButton,
} from "stream-chat-react";
import {
  ChannelSlot,
  ChatView,
  ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { AutoCloseSidebar } from "./AutoCloseSidebar";
import { SidebarProvider, useSidebar } from "./SidebarContext";
import { SidebarToggle } from "./SidebarToggle";
import "./sidebar.css";

const layouts: LayoutDescriptor[] = [
  { id: "channels", slots: ["channel", "thread"] },
];

// Declared once, so WithComponents gets the same object on every render.
const overrides = { HeaderEndContent: SidebarToggle };

// The toggle, then the back button the header shows by default.
const ChannelHeaderStart = () => (
  <>
    <SidebarToggle />
    <WorkspaceNavigationBackButton />
  </>
);

const ChannelsView = () => (
  <>
    <ChannelNavigation />
    <ChannelSlot slot="channel">
      <ChannelHeader StartContent={ChannelHeaderStart} />
      <MessageList />
      <MessageComposer />
    </ChannelSlot>
    <ThreadSlot slot="thread">
      <ThreadHeader />
      <MessageList />
      <MessageComposer />
    </ThreadSlot>
  </>
);

const ChatLayout = () => {
  const { sidebarOpen } = useSidebar();

  return (
    <div
      className={`chat-layout ${sidebarOpen ? "" : "chat-layout--sidebar-collapsed"}`}
    >
      <ChatView layouts={layouts} views={{ channels: <ChannelsView /> }}>
        <AutoCloseSidebar />
      </ChatView>
    </div>
  );
};

export const App = () => (
  <SidebarProvider>
    <WithComponents overrides={overrides}>
      <Chat client={client}>
        <ChatLayout />
      </Chat>
    </WithComponents>
  </SidebarProvider>
);

AutoCloseSidebar is the last part of this page. The channel list comes from the paginators registered on client.channelManager, as in the Quick start.

Add CSS for sidebar visibility

On a wide screen, the collapsed list shrinks to nothing and fades out. The SDK's stylesheet animates the change. The button in the channel header shows only while the list is collapsed:

src/sidebar.css
.chat-layout {
  display: flex;
  height: 100%;
}

.chat-layout--sidebar-collapsed .str-chat__channel-list {
  flex-basis: 0;
  min-width: 0;
  opacity: 0;
  overflow: hidden;
  pointer-events: none;
  width: 0;
}

/* One toggle at a time: the header's shows only while the list is collapsed. */
.chat-layout:not(.chat-layout--sidebar-collapsed)
  .str-chat__channel-header
  .sidebar-toggle {
  display: none;
}

Responsive behavior

Below 768px, the SDK's stylesheet hides the channel list and places it over the channel. Show it while the sidebar is open. Add this to sidebar.css:

@media (max-width: 767px) {
  .chat-layout:not(.chat-layout--sidebar-collapsed) .str-chat__channel-list {
    pointer-events: auto;
    transform: translateX(0);
    /* Shows at once; the SDK's rule delays hiding until the list has slid out. */
    transition:
      transform var(--str-chat__channel-list-transition-duration)
        var(--str-chat__channel-list-transition-easing),
      visibility 0s;
    visibility: visible;
  }
}

While the list is open on a phone, it covers the channel, and its header shows the toggle that closes it. Once it's closed, the channel header shows the toggle that opens it again.

Close the list when a channel opens on a phone, so the user sees the channel. openChannels from useWorkspaceNavigation() lists the channels the layout has open:

src/AutoCloseSidebar.tsx
import { useEffect } from "react";
import { useWorkspaceNavigation } from "stream-chat-react";
import { useSidebar } from "./SidebarContext";

export const AutoCloseSidebar = () => {
  const { openChannels } = useWorkspaceNavigation();
  const { closeSidebar } = useSidebar();
  const openCids = openChannels.map(({ cid }) => cid).join(",");

  useEffect(() => {
    if (openCids && window.matchMedia("(max-width: 767px)").matches) {
      closeSidebar();
    }
  }, [openCids, closeSidebar]);

  return null;
};

AutoCloseSidebar renders as a child of ChatView, because useWorkspaceNavigation() returns the layout's navigation only below it. Children of ChatView render in every view. Navigation explains useWorkspaceNavigation().

The thread list has the same header slot. To collapse it too, add a threads view with ThreadList and apply the same CSS to the thread list's root, .str-chat__thread-list-container.