Rail Panel Sidebar – Icon Rail with Context Panels, Pin to Collapse and Hover Peek
Navigation 2
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>
);
}
















