Skip to main content

Chat

ThreadRail

A thread beside its conversation: a labelled landmark with a header and a close button. Escape closes it. It takes focus when it opens and gives it back when it closes.

Design reviews

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.68

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/thread-rail#v1.3.68

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/thread-rail

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/thread-rail#v1.3.68 ourostack/ouro-ui/thread-rail-tests#v1.3.68
import { ThreadRail } from "@/components/ouro/thread-rail/thread-rail";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<ThreadRail label="Thread" title="Thread" detail="Design reviews" meta="2 replies" onClose={closeThread}>
  <MessageList label="Replies">{replies}</MessageList>
  <Composer label="Reply in thread" onSend={send} />
</ThreadRail>

Use instead when

  • Use Sheet instead when the thread should cover the page, as on a phone, and close on a tap outside it.

Props

Type: ThreadRailProps.

autoFocus
Type: boolean
Default: true
Whether the rail takes focus when it opens. Pass false to leave focus where it is.
children
Type: ReactNode
The thread: usually a MessageList and a Composer, which fill the rail's height.
className
Type: string
Layout only: the rail's size and margins. Appearance classes warn in development; the colors come from the theme.
closeLabel
Type: string
Default: "Close thread"
The close button's name, which its tooltip also shows.
detail
Type: string
One muted line under the heading, cut short with an ellipsis: the conversation the thread belongs to.
initialFocus
Type: RefObject<HTMLElement | null>
The control that takes focus when the rail opens, instead of the rail itself.
label (required)
Type: string
The rail's name for screen readers when title is left out, such as "Thread". Required, so the rail has a name.
meta
Type: ReactNode
Muted words after the heading, such as "2 replies".
onClose (required)
Type: () => void
Called when the close button is pressed, or Escape is pressed with focus in the rail. The app removes the rail in response.
returnFocus
Type: RefObject<HTMLElement | null>
Where focus goes when the rail closes, instead of the control that had focus when it opened.
title
Type: string
Default: the label
The heading's words, which screen readers also hear as the rail's name.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • onOpenChange: Not a prop. The rail closes through onClose: <ThreadRail onClose={() => setOpen(false)}>.
  • open: Not a prop. The rail shows while it is rendered: {thread && <ThreadRail onClose={close}>}.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.