DHxWhy

Rail Panel Sidebar – Icon Rail with Context Panels, Pin to Collapse and Hover Peek

Navigation 2

About

Rail Panel Sidebar for React and Tailwind CSS (shadcn/ui): an icon rail plus a context panel per section. It is built to show where you just went: the active marker slides along the rail and the panel content enters from the direction of travel. Unpin it and the panel folds into the rail; hover or focus the rail and the panel peeks over the content as a floating layer. Controlled or uncontrolled activeId, pinned and peeking, arrow keys, Enter and Escape, aria-current, reduced motion, light and dark. Use it for workspace app shells. The included demo plays a scripted tour on local data and makes no network requests (inline avatars). Requires React only, with no animation library.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Rail Panel Sidebar – Icon Rail with Context Panels, Pin to Collapse and Hover Peek to your project and fits it to your theme.

Code

How Rail Panel Sidebar – Icon Rail with Context Panels, Pin to Collapse and Hover Peek is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { ChartColumn, FileText, FolderKanban, House, Inbox, Settings, Users } from "lucide-react";
import { RailPanelSidebar } from "@/components/ui/rail-panel-sidebar";
import type { RailPanelContext, RailPanelSection } from "@/components/ui/rail-panel-sidebar";

type DemoProps = { autoplay?: boolean };

const HEADSHOTS = {
  "photo-1573497019940-1c28c88b4f3e": "data:image/webp;base64,UklGRo4CAABXRUJQVlA4IIICAADQDQCdASpAAEAAPpVAl0glpCIhLBquYLASiWIAqo/e0WKUIW1UlGg0LODnyPg8llhEqlTGJelz4+tInPnyRdqamVIqk4zISyDDKvRRclPiXARfbVTIDnqdAXbqf/NT4lw5QBKHVGRo9hY5Hp9A0G+Th1UqpTuPwAD9BNJ9UR4uJb+svYvPzl3dGJPHG99cMBxgeRDDdk5zMt/1lvz/Dk4fiv6+h0Sb4aW09CTC75wXnogFs/Vm09QuGsc1yDQYkh74X0/JvNukugjBMp474kpyG1CRNWa4aB1co5XTStoWmR0ejpugEpN2d3osMKgL+5p6O+t6JWLl9KXnMcQ0wxqTyOobZtt6JGGN/b/q+uRU0p1JH0BLdeAZ0i5P8QkxgKIIx4KtlO761Q5WVk9uE+csE00WkLIsnl35nSy0+BOQGSTJdnsPrDHJ//aPg10yVu7mO9Aehrtdso1HZW61NVfBetpg5ExOVcs15elLA+QYOtNeN8/eqs2oH+a00ecG0r38+VRLthoNLxcWMN+qlyC7ZGsZymuUGMmTB6MH7k92X4qljAiaNvF7zBGU6IOkUpH9K0QUPZW7Nm5ZLLMBgvvgzOCAHddyHWVjznl6lIisSs6IEEDIsVlMVfPGchKbCPopH1MjhU4EN9xHi858TyIvNEv1sPMV6lddIcFTUl9dfj/Gb6cjmtTk3eWT6hM3xWNYGw7TduyhhsM1j9UTv/MHZk/76KEtmbZ2jBwY5R0Izs+7pbKgroTCoDkH4vOpTuqDW3mY+OVt4ec5vIvflLVkCxXuWZSZiU16cH4hh7uHxZ0sA8if/8ZTCpWCG3zeV5usf9z+mszT5Sevb8CLWhCgAAA=",
  "photo-1560250097-0b93528c311a": "data:image/webp;base64,UklGRvICAABXRUJQVlA4IOYCAABQDgCdASpAAEAAPpVAlkglpCIhLhtriLASiWMAxQw+Tcl+unTu23aUWsvTZ/6UBmb5q/vSM/c1T1VG3j60qt84QAvZ7zxfNONqxyhaR31n+Y0Qi7FPCS6YSL7zvqG4KQDuRV4nARHQFRYxbK1vmMAqELO7qQxm2CoGCAAA/v025ZrPbB7cgIffyHRaWOO2SkNEaagn4fVhFSxYpVlx57ShkY42HQ4ZO1u/ZOVpdj4WM4/8ewlLAj8jK3K5SGll3im17eZg7at9gcYhjSu+TZ8hvd3rveMwN8yWWYTwdC3fCegRxzJr6ti5WHJV6chmE+U7zIrcX7LFB8kCcDcDjDBnU4ohwB+o0C3eL+WryygdtR0dsYi0E7D6yXQZWIzcqzwGvlxf+5vj4cRwrwVtLzIcMi0oLyVmUKQz26hkNvNmZwPY+Ct1TMmJtS1Y8RMcDHPZhEJuUKEQSWmXJ2c2EBFYlsLszBjXWHISvhQOk1s9tr8IhTnzau4/YXhiIDEXLMOHIHl2aw8RdrAk7LUqjn9ow9LUMI48HsTCiBDZU9L3n41o2kuOH642wUlnDfn0ccDfsDTPkkC4AAqJfX0yTOolxh2NYrL4CfSKsDD53m4bSlH1aocDEZzudTuKXeN4vxQmzBP8c3rZJ/8hh+qzFNsozH8pqvQ9abtTxuO8b7D0K/BQ+92STJ8ArDcn5gPG+pXZ8WJbeCzvHSDOr/78skca8TpPYmf74MEYysvf6V9gqPJqfcnk2G+oC1GqZGt4uYfI6RW0cdZycfhBgYdddn8zaW6y/rugGIRbyc9lQI7ye+ADUW9V/sUK6TcYuPg44BtxqkxEDjgEn2zXxn4L+gbFV4L3K4DWXBFGf6nxlmHx7SIDa19uGeAzxgG2Kjywk/Ld4U7WtPURPgN+rofjUWiBGc+dbrvyHBi4sWR3PEdOxz/PU/WXfMcickqzPWrBy23P8TNWv6x00Vn7PLs9FVe29AHZAAAA",
  "photo-1573496358961-3c82861ab8f4": "data:image/webp;base64,UklGRgYDAABXRUJQVlA4IPoCAADwDwCdASpAAEAAPpVEnUqlo6KnKhZpcOASiWMAzIwPvXJrRqTt2vrLCmXV9eq65dBRuWlh4RBuarCqvRNHzBCLq2SsaIW45gv0UMqs5su8586TxvmInKZ3o98qoWxrCWGLy03U7Lm+AJ5iEJtaTKPTwWal5T6DsJzWk61BL33NH6yO25i5S2VgAP76l9iRXzMKAbkTS1HfR2GEsEvyLnpj+rltzQAJh2wWhfKMkRNcd6RJOYoVZT6TUcpbvkAipH9tRuJK0NgP2E9B9gLy7iV/UtmVJn8Dh4TPG9csa1QTdD8uCzCIa/46OOx0V6QTZCsWi54vscUeNdMN2C8xnuLL6OU4/LGz42UiN/c/2w8w35a758nDs8pq5+HaYGBH2GMtNoFkT4rObHWB8jXS/ydbHeYtG/WxYbyIfS5Azh6em/Byunxdxpj+Uqp++dWevfTII3lU+BCorx30NVlJjdx30Vq/8XJFOYSj578iwLjCXRnZ2u9LCavf3XPWIpyQ9B4S8wKuORZ4ASCnTGmIpyDmpylNQkDs+y1Lllw1DJorXrmh2r4mUKn8Y7+DQQCQKgPnv3urDJ8qvmauNO9UYK50aKp45K7jT/Vxcs+fSqn9vum8eKKyc05xBAbt/4WO8nC3Q//iIOYksZL+ucZlwKtGsoZtrq4DI/p4e5pq3ix4ps4ZaBTMGitkKaBQpen0aDYpHpfW1jyOMNcjZcAVjvNc2ktJpbBjgLSW6+TwdXE/P91j06Gd/UhU9itsF8vUdo17KLZdH4sn7y2W9Ag8slmx7FWOKvn+bohLcXkJCVL3Nmxghb+Md6B/gbFNs6JQ0WMfCbrDQ3cRM96xUD1SNaN1EeR8POjjW6cf4FRGKnjQOrAaoMXYwU4QN3ZnQH14BHNa3+Cx/+6JB365HoRM4D5kr/p9aevfhBwnvir74RTI1odb44XhDkqgIXxXvSMyotV1Tm6h7STRIdrGYCxFNIjnxozyU7h9/iwUOZP4mcJIiaBvGXq7gSKAAAA=",
  "photo-1540569014015-19a7be504e3a": "data:image/webp;base64,UklGRp4EAABXRUJQVlA4IJIEAADQEwCdASpAAEAAPpVEmkklpCIhKhZsWLASiWIAsR+YDQihb5ekDb0Mxo3Z/cvAvyie3JF3cFqI9q8AsxNAB+S/1fv39VBXlNP82UN1C5jYL4pkUUPfRzdg7W9l74YFR06KzZzHO3qxnWrAbyJVGhm8bKV22M5l7suH+t41Mb6DuXUotXXmGdsIWz/d2XCebFdHhrpyAXHuS8ahi+kcalgXayc4TGCvMAD+/dpU3DK3rzJNsCYSoW0fV5s28Z4TzFuZZsEnIVTMc8cQjkJbDmbHxmxO3KHtqYmggYpD6HpPhG2EyzezFOv8ftqQU/yH/3fRvel+/WCY+ypUhPa+DLUZrqKlDlRB2ZiRLMLQ/INC8+7jfXWVd04Puf19ogVK7U1LO/h400y2tDIcjr9r38+KJrq2YPzWwbgVLIYtK5rv4Z9kCk7r2zzuG+Dpp6hSGKs11+R9yvYWvV30VdOaNPiYn0Uhhu+YUtqb4B5de24b+q6de3hIGQVuow5C1V+SZ3bBFLhSWXVnu6JP251uG3q040Eok+Pc3TvK2v5WWhdFvzJsN3e3FJlXZMg9ePAfpzBG89i0wG394+9iPMFE9aGiOzFDRpRJPImpkvLdQmDtuhiVbqln5BpLnmXeUjFf0UXzTsTW92V54b+N1Hw1/NAflG77RVFmw6n+XbL3bFwM1DT7gMcLffAuA03bs9BTfEpgtMvKozCYPWzdWt43KDJn0Xj0EOqB8JNgS+PJZccuNwARkbCD9WV/teO/Cn/lOi8FKFrk+AQepvUQjfIT9YtYlJucXyOAopSxHDKNIZTccI1KQoO835mincC4UqWqe9XYZ8YK9v1O5JSd3Nc9vBg42X5REHursiT4Mh3LejHeSfcRQ8CtfugpKqqmAvyrFh0eYMlFlgo1v/ftqnn0igcqddgKhnutPLQ5rxNhfIrF7GTPqXj+eQ9i9fAjz7R0M0MkTs2QbPY1PS2hfmNjmLzAP0JK0v7nuheabDRqDzbNOgxfQvGGfJkRnJXPmqpJGvqvvFCJByo3W9DYuC2wAwLXTL+nm9K/WxHO7kTZr7D82tquax/rVq8fxr14SQKT3xU7wh4zho0W+w61GWuU0ByOZruHdIILwqIKnoHRBcJBPXz+8ufrA8LWTZaGbDjcULYSCnKyGyFjAjI8eb6fLfUY8vKOZdm2nkKBMqN5EaGej59r4a415TUf0hf7aihq3X7fTMv5aU+scKdhKMX5cSMJKoQEzkG5G+xbLqRMq6G/rDOBCNwFl4m8q0z+BJJKIAAP36mE8BndQFK2eXiMdwmpvxsBNEL8WVXB38DbY544wEEglj7j1AFNerLEMbA/W5nLLiZggdZr6lxRBd6KuKAgKgLgHuhkQ/uOqM7RuvVPbJsdgorthz0GnmM/1kmAAiGXW19JrTwJCvxqv9IO6IB8lPar+CaCtk0ouQ8IBCRe4+qx4MfOczMHsGCe4F6qzWg+PrRK3gc1Hta948M+BwTsQ331b52pYQhiWrfuUVUJjTZN4dO1UQ7mYEVftNvNL5VcM4HKOxltVvZ/uLxc3DNxkAA=",
  "photo-1699899657680-421c2c2d5064": "data:image/webp;base64,UklGRqoCAABXRUJQVlA4IJ4CAADwDQCdASpAAEAAPpVEmEilpCIhLBgMyLASiWUAzHm9LDOOAtaDTVb00rzEfNd8j8Maog2LjEGafO7UHPkQCGqnwDXU7F/X24AwUkwg1WlM1w+iZnM2OfQc9LuUCUclbv7TNNKOpX7Tk2A43hPRcLHV7BxA7oj+3GgA/vnNZzSRHNIU8wj+FzyxOAbU4DsCWyd4IMP8WyR7efB3+Z3p7iBPu/+u16oU2jSviquMjBdunwiP2F91OXLdScqxmFMzw3xAXUEDbjIq3zo2lGDc4xJP/AmQHoANCVd+4+7CFMI/qw9FcVW9uXeDhEGpWUbkG1yt00KOBt9EKHv/IcX+bMiPpTSt3tOi2VBXK5uiBaG70hGytppIuKwfR7as6INle1fnyMKPrzdz2sAYMnyqHjL/M+rpzyQJr6sA1es9AWvjPlCH7+qTAuc8UlYswyZfkmPRC+/G90se8VdB+Xc/4JhegvzuJzgIHAZVHM9HezZT6OPhWRCVyccsUUaG7k1cyGfNv/0pPqXUWwtQUmjxWXqkYRAiBejKQvCvFgMk4R3N4FSK3GVH9S6ih1OZCK1KsTXSjR/NAzsnmUg5qDeOrsiVY/uCW8YUZMzyjFhWVfJV2zOfMP2cXAFP/pvPUIo6XEKL581Tq0rdQrOhtGDpAolMcaerpRUU0PfVJOO7sIL+lhUBtqAwpt4e+r5fCpR0xKWX83xCfDmT379HB4jBN8K82GdkN1k+ofCjpXiFWQQPWypVbkVjzUI+JrVjA4zRaHElGJ3fLOdK3QuECZJqix8c/mgwYgiKpuzv9f1lZl0fPcEmVnB3E6JWRRB5XVrJvI+7RSEdkVyNYKsctAyWBVTXjI320Q7vGsfOemzL1db+tZFf2aXklQHteZZgAAAA",
  "photo-1595211877493-41a4e5f236b3": "data:image/webp;base64,UklGRsYBAABXRUJQVlA4ILoBAACQCgCdASpAAEAAPpVAmkglpCKhMBYLALASiWcA0JdvORgLVu3sc0bGyc5wqvfIZS6oSxCiuV9c4wJKv5aPnvSRm4up0Ny8Zvs2rkcQlte74Xy+/KBHDze0iSC1WAAA/vpvYT//809W1YVksV8DXsujv603vu5Gic/On6axkF3TxA/s55RVcJRMAgyHOfUdacUjvCChGKzZDAtvRME3hMZ5+TnoVhlDpiplEPyYvSMxrBNAkToTphvMSqvVD2oJND/0y04J1BVVv2fwkZqm3RM3jiHJAhBfEfWpwKgol3AAKzaETUFFmhD7IdI/NrTvV3LVYRB3z4EHwTZly5+bwf9KgR4RiJILeq1c7VzWLJLTgp5VzYvd7NWH9/P0cxhQ1ETXlQHft59gPYfQ+l7225Aj4lwHzetd7PpCTCt1FaVtYDjH/9DhPHmqaGGkeNF4Gz77xw16vA0RvT3WVu4Y7po6jVjK5aBfqRgNBJRPWLh+xpPirRt+Ga003dPw6DF0TdSXZOlc9pd+98yuSZ+9qfRmNJhN64EXSul87O7y4zY59jwDg+26EPgEy5Yw6xBscBD/ZG4r6VFPAAAA",
  "photo-1494790108377-be9c29b29330": "data:image/webp;base64,UklGRvgCAABXRUJQVlA4IOwCAACQDwCdASpAAEAAPpU+m0oloyIhqhZroLASiWIArSPw/6RZdNEhuFtD+DU2L2ADgl1gkaQSc6UXRcHmqp0XTiRUevUvcRHQYotfh4fKgHeeNWfq2+bmlVJ+R3APMyHOkKfLdYsXkfD2LS+I3Pcmq9puEzwU4VkTxZlfeUo0N/krZWqbNiqAAP7zNgM1T5zbwVgAaw94inyV1hR2T3/TA0c9u9cekakO9OmxrQukQY1SZyMXta5nouLNZYqfbPPw98mULR1shHxPAJtRoPycGwpX4cy6/C2u5mpOCo+ctkxwHI7d0vl4MRYHBxgo1IDZIzdqxaI2aAnbm7XPPbDr5TbIq3Jl3pHFr1g6V+JUTam8m9jr0S1eMl5/8KWE6pmb3+vsy6+FnUpRK/pg0FFqvkMJv2Ya8njL0AwGjg/dIc+1wdizpbA1BdtrinDhxqF7GBWkkZ5uZyN8mhARHXsQrn9Bw4FEAoJbZK3xE91W8gZEUOYB4MJuKouH7UX94G68hA/DvCQodJ4FWI+iD8blc8lOzDPq3YNE/GJ/tXZspjNmQCMwU75UdTTKuPs9si5pWdwhlirx8SbdGbyt3/39SilKgvoCVtLb2XWcB2MxyZTVhIWKGlhyG7sesh6YGGDHJd9h0bMMC4PyBE6LJxc5SXk9qb3fHFMXNq2i8Drr2HgUCXzfDfZUeD/LOXw3HdqBcery+pAMdC+AVmEtHwffpUQI0zX/OyJestEFpeIRDHn+3puGJcZxRwSigEPkFJnvv02MjMBOiDsC8JZewtlYFaSUt0XvCzdKFUeJYXkPUAJyJsE7/gPxnTLSpIOcO39pOXvN1/fQqT9+tR4TZUnJfDbZckjPaFFebnSe9Wfprz/SwfydNbqRX8hUGYPHkwcDqLthqPxOCygFdgByGCwhwdEYvzYPWN2W3ydZVJrA03TuCGoCDzOPmGKQMWTU3UfsHI5IU+ByFXuPY9r3G9SluNmkrHur5aEO9o3jKgAA",
  "photo-1705645930353-0e335311ef20": "data:image/webp;base64,UklGRoACAABXRUJQVlA4IHQCAABQDgCdASpAAEAAPpVCnUklpCMhLhko4LASiUAY8/zwKxVW73Ztcpjd7RfdAHArrdx4uq8gvwE6xePm9mN2of5FOxussxZqwJ3swPAmvo9ZW9IPyXQCvpQDrp3HHRtGnixty/R13Oso+q5RcaO1d/OyKvH4aGge/F5MFAAA/t2+Hj0BvJuJTsvvMHu/xSzwgAFxfV9VJZdfKnOrzKje6SCWiMQz4D6ZFVOw/zdAsQ2LHKzGnVSu2+rI185a+u5Ni/8c/dgsBRyv9KwVZPHvd1AqlwbvUCSsWgIKbGx10Bmj3FU4pz5SY/p0wQTdW97fhrxmYzdziOWoixdxmn7fBlc4+I/LvC9asCSXLTcTP2pAs+yxhFHk8OWZpOCwEJkHYgx1GohfacuSbBYJ/pDB8iP4qJ5i5COxPrrjQ8B74N+beN+rZld8/lP4ebWBVC48ABNvGNp0EBKaL/YFOVke9YU6dh0aM+PcgaEvJRNHMsSrbcXD8Lchk9+1gzzW+pgAV7LGQPGXqq7/O3opkujLkS+8oF8F7VqtmqS9DR8dzwdk466IaIkCbkA96a9zl6J5rDLTj+5ccqy2qRbqyxLI/rb679cHJjSfaKmX1gQfZRTWyaNvIP05quhXS9vImsGG5SX0VX6KbMMwJtCGIpqDYjh1/BvdQpdpbDCzdXQggH+u+vNDL2BY6B51YhaNI/ET/u8vMwanxSOyP7bdYWy5KUIHiop1RTEzKjTG/2mN/V98VeA+cXqFTh6QbZlZfESOsiRZdWntK75r48T4jJER4nwLuRUYgGhYx0TlDmN2teGF0vHlmV/bpqP4XdScIaI2XlHJAwAA",
} as const;

const photo = (id: keyof typeof HEADSHOTS) => HEADSHOTS[id];


const PEOPLE = {
  maria: { name: "Maria Lopez", role: "Design lead", photo: photo("photo-1573497019940-1c28c88b4f3e") },
  tom: { name: "Tom Weber", role: "Engineering", photo: photo("photo-1560250097-0b93528c311a") },
  aisha: { name: "Aisha Rahman", role: "Product", photo: photo("photo-1573496358961-3c82861ab8f4") },
  daniel: { name: "Daniel Kim", role: "Engineering", photo: photo("photo-1540569014015-19a7be504e3a") },
  hanna: { name: "Hanna Berg", role: "Marketing", photo: photo("photo-1699899657680-421c2c2d5064") },
  marcus: { name: "Marcus Reed", role: "Finance", photo: photo("photo-1595211877493-41a4e5f236b3") },
  clara: { name: "Clara Novak", role: "Support", photo: photo("photo-1494790108377-be9c29b29330") },
  felix: { name: "Felix Brandt", role: "Owner", photo: photo("photo-1705645930353-0e335311ef20") },
} as const;

type Person = (typeof PEOPLE)[keyof typeof PEOPLE];

const PROJECTS = [
  { title: "Website redesign", status: "Active" },
  { title: "Mobile onboarding", status: "Review" },
  { title: "Q4 campaign", status: "Draft" },
  { title: "Billing migration", status: "Active" },
  { title: "Help center refresh", status: "Review" },
  { title: "Partner portal", status: "Draft" },
  { title: "Brand assets v2", status: "Active" },
] as const;

const STATUS_DOT = { Active: "bg-chart-2", Review: "bg-chart-4", Draft: "bg-muted-foreground/40" } as const;

const MAIL = [
  { from: PEOPLE.maria, subject: "Design review notes", time: "09:41" },
  { from: PEOPLE.tom, subject: "Re: staging deploy window", time: "09:12" },
  { from: PEOPLE.aisha, subject: "Onboarding copy, round 2", time: "Nov 7" },
  { from: PEOPLE.daniel, subject: "Billing migration checklist", time: "Nov 7" },
  { from: PEOPLE.hanna, subject: "Q4 campaign brief", time: "Nov 6" },
  { from: PEOPLE.marcus, subject: "October invoices ready", time: "Nov 6" },
] as const;

const REPORTS = [
  { title: "Weekly active users", bars: [4, 5, 5, 6, 7, 7, 9] },
  { title: "Conversion funnel", bars: [6, 5, 6, 4, 5, 6, 7] },
  { title: "Revenue by plan", bars: [3, 4, 4, 5, 6, 8, 9] },
  { title: "Support response time", bars: [8, 7, 7, 6, 5, 5, 4] },
  { title: "Churn by cohort", bars: [5, 4, 4, 3, 4, 3, 3] },
  { title: "Marketing spend", bars: [5, 6, 4, 6, 7, 6, 8] },
] as const;

const TEAM: Person[] = [PEOPLE.felix, PEOPLE.maria, PEOPLE.tom, PEOPLE.aisha, PEOPLE.daniel, PEOPLE.hanna, PEOPLE.clara];

const SETTINGS = [
  { title: "General", detail: "Name, URL, locale" },
  { title: "Members & roles", detail: "12 members" },
  { title: "Billing", detail: "Team plan" },
  { title: "Integrations", detail: "4 connected" },
  { title: "Notifications", detail: "Email, push" },
  { title: "Security", detail: "SSO, sessions" },
] as const;

const RECENT = [
  { title: "Website redesign", kind: "project", when: "2h" },
  { title: "Design review notes", kind: "message", when: "3h" },
  { title: "Weekly active users", kind: "report", when: "Nov 7" },
  { title: "Q4 roadmap", kind: "doc", when: "Nov 7" },
  { title: "Billing migration", kind: "project", when: "Nov 6" },
  { title: "Release notes 3.2", kind: "doc", when: "Nov 6" },
] as const;

const RECENT_ICON = {
  project: <FolderKanban className="size-4" aria-hidden="true" />,
  message: <Inbox className="size-4" aria-hidden="true" />,
  report: <ChartColumn className="size-4" aria-hidden="true" />,
  doc: <FileText className="size-4" aria-hidden="true" />,
} as const;

const matches = (query: string, ...fields: string[]) => {
  const needle = query.trim().toLowerCase();
  return needle === "" || fields.some((field) => field.toLowerCase().includes(needle));
};

function Row({ children, tall = false }: { children: React.ReactNode; tall?: boolean }) {
  return (
    <button
      type="button"
      data-row
      className={`flex w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm outline-none transition-colors duration-150 motion-reduce:transition-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring ${tall ? "h-11" : "h-9"}`}
    >
      {children}
    </button>
  );
}

function Empty({ query }: { query: string }) {
  return <p data-empty className="px-2 py-6 text-center text-xs text-muted-foreground">No matches for “{query.trim()}”</p>;
}

function Avatar({ person, size = "size-6" }: { person: Person; size?: string }) {
  return <img src={person.photo} alt="" width={24} height={24} loading="lazy" className={`${size} shrink-0 rounded-full object-cover ring-1 ring-border`} />;
}

function List({ children }: { children: React.ReactNode }) {
  return <div className="flex flex-col gap-0.5">{children}</div>;
}

function RecentPanel({ query }: RailPanelContext) {
  const rows = RECENT.filter((item) => matches(query, item.title, item.kind));
  if (rows.length === 0) return <Empty query={query} />;
  return (
    <List>
      {rows.map((item) => (
        <Row key={item.title}>
          <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">{RECENT_ICON[item.kind]}</span>
          <span className="min-w-0 flex-1 truncate">{item.title}</span>
          <span className="shrink-0 text-xs text-muted-foreground">{item.when}</span>
        </Row>
      ))}
    </List>
  );
}

function ProjectsPanel({ query }: RailPanelContext) {
  const rows = PROJECTS.filter((project) => matches(query, project.title, project.status));
  if (rows.length === 0) return <Empty query={query} />;
  return (
    <List>
      {rows.map((project) => (
        <Row key={project.title}>
          <span className={`size-2 shrink-0 rounded-full ${STATUS_DOT[project.status]}`} aria-hidden="true" />
          <span className="min-w-0 flex-1 truncate">{project.title}</span>
          <span className="shrink-0 text-xs text-muted-foreground">{project.status}</span>
        </Row>
      ))}
    </List>
  );
}

function InboxPanel({ query }: RailPanelContext) {
  const rows = MAIL.filter((mail) => matches(query, mail.from.name, mail.subject));
  if (rows.length === 0) return <Empty query={query} />;
  return (
    <List>
      {rows.map((mail) => (
        <Row key={mail.subject} tall>
          <Avatar person={mail.from} />
          <span className="flex min-w-0 flex-1 flex-col leading-tight">
            <span className="truncate text-[13px] font-medium">{mail.from.name}</span>
            <span className="truncate text-xs text-muted-foreground">{mail.subject}</span>
          </span>
          <span className="shrink-0 self-start pt-0.5 text-[11px] text-muted-foreground">{mail.time}</span>
        </Row>
      ))}
    </List>
  );
}

function ReportsPanel({ query }: RailPanelContext) {
  const rows = REPORTS.filter((report) => matches(query, report.title));
  if (rows.length === 0) return <Empty query={query} />;
  return (
    <List>
      {rows.map((report) => (
        <Row key={report.title}>
          <span className="min-w-0 flex-1 truncate">{report.title}</span>
          <span className="flex h-4 shrink-0 items-end gap-px" aria-hidden="true">
            {report.bars.map((bar, index) => (
              <span key={index} className={`w-1 rounded-sm ${index === report.bars.length - 1 ? "bg-chart-2" : "bg-muted-foreground/25"}`} style={{ height: `${bar * 10 + 10}%` }} />
            ))}
          </span>
        </Row>
      ))}
    </List>
  );
}

function TeamPanel({ query }: RailPanelContext) {
  const rows = TEAM.filter((person) => matches(query, person.name, person.role));
  if (rows.length === 0) return <Empty query={query} />;
  return (
    <List>
      {rows.map((person) => (
        <Row key={person.name}>
          <Avatar person={person} />
          <span className="min-w-0 flex-1 truncate">{person.name}</span>
          <span className="shrink-0 text-xs text-muted-foreground">{person.role}</span>
        </Row>
      ))}
    </List>
  );
}

function SettingsPanel({ query }: RailPanelContext) {
  const rows = SETTINGS.filter((item) => matches(query, item.title, item.detail));
  if (rows.length === 0) return <Empty query={query} />;
  return (
    <List>
      {rows.map((item) => (
        <Row key={item.title} tall>
          <span className="flex min-w-0 flex-1 flex-col leading-tight">
            <span className="truncate text-[13px] font-medium">{item.title}</span>
            <span className="truncate text-xs text-muted-foreground">{item.detail}</span>
          </span>
        </Row>
      ))}
    </List>
  );
}

const SECTIONS: RailPanelSection[] = [
  { id: "home", label: "Home", icon: <House />, searchPlaceholder: "Search recent", panel: (context) => <RecentPanel {...context} /> },
  { id: "projects", label: "Projects", icon: <FolderKanban />, searchPlaceholder: "Search projects", panel: (context) => <ProjectsPanel {...context} /> },
  { id: "inbox", label: "Inbox", icon: <Inbox />, badge: 3, searchPlaceholder: "Search inbox", panel: (context) => <InboxPanel {...context} /> },
  { id: "reports", label: "Reports", icon: <ChartColumn />, searchPlaceholder: "Search reports", panel: (context) => <ReportsPanel {...context} /> },
  { id: "team", label: "Team", icon: <Users />, searchPlaceholder: "Search people", panel: (context) => <TeamPanel {...context} /> },
  { id: "settings", label: "Settings", icon: <Settings />, searchPlaceholder: "Search settings", panel: (context) => <SettingsPanel {...context} /> },
];

function Brand() {
  return (
    <span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground" aria-label="Alder">
      <svg viewBox="0 0 24 24" className="size-4" fill="currentColor" aria-hidden="true">
        <path d="M5 19c0-7.2 5.8-13 13-13 0 7.2-5.8 13-13 13Z" />
        <path d="M5 19c2.6-4.4 6-7.8 10.4-10.4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" className="text-primary" />
      </svg>
    </span>
  );
}

function Placeholder() {
  return (
    <div data-placeholder aria-hidden="true" className="flex h-full min-h-0 flex-col gap-4 bg-muted/40 p-6">
      <div className="flex items-center justify-between">
        <div className="h-5 w-40 rounded-md bg-muted-foreground/15" />
        <div className="h-8 w-24 rounded-lg bg-card ring-1 ring-border" />
      </div>
      <div className="grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-4">
        {[0, 1, 2].map((index) => (
          <div key={index} className="flex h-28 flex-col justify-between rounded-xl bg-card p-4 ring-1 ring-border">
            <div className="h-2.5 w-1/2 rounded bg-muted-foreground/15" />
            <div className="h-6 w-2/3 rounded bg-muted-foreground/10" />
          </div>
        ))}
      </div>
      <div className="min-h-0 flex-1 rounded-xl bg-card ring-1 ring-border" />
    </div>
  );
}

type Step = { at: number; run: () => void };

export default function Demo({ autoplay = true }: DemoProps) {
  const params = React.useMemo(() => new URLSearchParams(typeof window === "undefined" ? "" : window.location.search), []);
  const controlled = params.get("controlled") === "1";
  const [active, setActive] = React.useState("home");
  const [pinned, setPinned] = React.useState(true);
  const [peeking, setPeeking] = React.useState(false);
  const [requests, setRequests] = React.useState([] as string[]);
  const [motion, setMotion] = React.useState({ ready: false, reduced: true });
  const [paused, setPaused] = React.useState(false);
  const [compact, setCompact] = React.useState(false);
  const [run, setRun] = React.useState(0);
  const rootRef = React.useRef(null as HTMLElement | null);

  React.useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setMotion({ ready: true, reduced: media.matches });
    update();
    media.addEventListener("change", update);
    return () => media.removeEventListener("change", update);
  }, []);

  React.useEffect(() => {
    const media = window.matchMedia("(max-width: 639px)");
    const update = () => setCompact(media.matches);
    update();
    media.addEventListener("change", update);
    return () => media.removeEventListener("change", update);
  }, []);

  React.useEffect(() => {
    if (!motion.ready || !autoplay || motion.reduced || paused || controlled) return;
    const root = rootRef.current;
    const started = performance.now();
    let count = 0;
    if (root) {
      root.dataset.tourStarted = started.toFixed(1);
      root.dataset.tourStep = "0";
    }
    const script: Step[] = [
      { at: 600, run: () => setActive("projects") },
      { at: 2200, run: () => setActive("inbox") },
      { at: 3600, run: () => setPinned(false) },
      { at: 5000, run: () => setPeeking(true) },
      { at: 6200, run: () => setPeeking(false) },
      { at: 8000, run: () => setPinned(true) },
      { at: 8800, run: () => setActive("home") },
    ];
    const timers = script.map((step) => window.setTimeout(() => {
      step.run();
      count += 1;
      if (root) {
        root.dataset.tourStep = String(count);
        root.dataset.tourLastAt = performance.now().toFixed(1);
      }
    }, step.at));
    timers.push(window.setTimeout(() => setRun((previous) => previous + 1), 10000));
    return () => timers.forEach((timer) => window.clearTimeout(timer));
  }, [motion.ready, motion.reduced, autoplay, paused, controlled, run]);

  const takeOver = () => autoplay && setPaused(true);
  const note = (entry: string) => setRequests((previous) => [...previous, entry]);

  return (
    <main
      ref={rootRef}
      data-demo="rail-panel-sidebar"
      className="w-full bg-background p-4 text-foreground"
      style={{ height: "min(100svh, 792px)" }}
      onPointerDownCapture={takeOver}
      onKeyDownCapture={takeOver}
    >
      <RailPanelSidebar
        sections={SECTIONS}
        activeId={controlled ? "home" : active}
        onActiveChange={(id) => (controlled ? note(`active:${id}`) : setActive(id))}
        pinned={controlled ? true : pinned}
        onPinnedChange={(next) => (controlled ? note(`pinned:${next}`) : setPinned(next))}
        peeking={peeking}
        onPeekingChange={setPeeking}
        width={{ rail: 56, panel: compact ? 212 : 248 }}
        brand={<Brand />}
        footer={
          <button type="button" aria-label="Account" className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card">
            <Avatar person={PEOPLE.felix} size="size-8" />
          </button>
        }
        className="h-full overflow-hidden rounded-2xl shadow-sm ring-1 ring-border"
      >
        <Placeholder />
      </RailPanelSidebar>
      {controlled && <output data-requests className="sr-only">{requests.join(" ")}</output>}
    </main>
  );
}

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    900
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    851
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    444
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    331
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    280
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    248
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    222

More in Navigation

View all
  • Sidebar

    Sidebar

    342
  • Pagination

    Pagination

    99
  • Oversized 404 Numeral

    Oversized 404 Numeral

    68
  • Stacked Circular Footer

    Stacked Circular Footer

    443
  • Navbar

    Navbar

    6
  • Navbar

    Navbar

    75
  • Tabs

    Tabs

    33
  • Tabs like bookmark

    Tabs like bookmark

    73