Web InnoventixFreeCode

Rail Sidebar

Original · free

narrow icon rail with tooltips

byWeb InnoventixReact + Tailwind
siderailsidebars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/side-rail.json
side-rail.tsx
"use client";

import {
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import {
  AnimatePresence,
  MotionConfig,
  motion,
  useReducedMotion,
} from "motion/react";

type IconRenderer = (props: { className?: string }) => ReactNode;

interface RailItem {
  id: string;
  label: string;
  group: "main" | "system";
  badge?: number;
  icon: IconRenderer;
}

interface Highlight {
  label: string;
  value: string;
  note: string;
}

interface SectionContent {
  kicker: string;
  title: string;
  body: string;
  highlights: Highlight[];
  meta: string;
}

const IconBolt: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="currentColor"
    aria-hidden="true"
  >
    <path d="m13 2-8.5 11.5a.8.8 0 0 0 .64 1.28H10l-1.4 6.9a.6.6 0 0 0 1.08.46L18.5 10.5a.8.8 0 0 0-.64-1.28H13l1.4-6.9A.6.6 0 0 0 13 2Z" />
  </svg>
);

const IconGrid: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <rect x="3" y="3" width="7" height="7" rx="1.6" />
    <rect x="14" y="3" width="7" height="7" rx="1.6" />
    <rect x="14" y="14" width="7" height="7" rx="1.6" />
    <rect x="3" y="14" width="7" height="7" rx="1.6" />
  </svg>
);

const IconLayers: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="m12 2.5 9 5-9 5-9-5 9-5Z" />
    <path d="m3 12 9 5 9-5" />
    <path d="m3 16.8 9 5 9-5" />
  </svg>
);

const IconChart: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="M3 3v16.5a1.5 1.5 0 0 0 1.5 1.5H21" />
    <path d="M7.5 15.5v-3" />
    <path d="M12 15.5v-7" />
    <path d="M16.5 15.5v-4.5" />
  </svg>
);

const IconChat: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="M20.5 11.4a7.6 7.6 0 0 1-8.1 7.6 8.4 8.4 0 0 1-3.5-.7L3.5 20l1.3-4.6a7.6 7.6 0 1 1 15.7-4Z" />
  </svg>
);

const IconCalendar: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <rect x="3.5" y="4.5" width="17" height="16" rx="2.4" />
    <path d="M16 2.5v4M8 2.5v4M3.5 9.5h17" />
  </svg>
);

const IconFolder: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="M3 7a2 2 0 0 1 2-2h3.7a2 2 0 0 1 1.5.7l1.1 1.3H19a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />
  </svg>
);

const IconGear: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <circle cx="12" cy="12" r="3" />
    <path d="M19.6 14.3a1.5 1.5 0 0 0 .3 1.65l.05.05a1.85 1.85 0 1 1-2.6 2.6l-.05-.05a1.5 1.5 0 0 0-1.65-.3 1.5 1.5 0 0 0-.9 1.37V20a1.85 1.85 0 1 1-3.7 0v-.06a1.5 1.5 0 0 0-.98-1.37 1.5 1.5 0 0 0-1.65.3l-.05.05a1.85 1.85 0 1 1-2.6-2.6l.05-.05a1.5 1.5 0 0 0 .3-1.65 1.5 1.5 0 0 0-1.37-.9H4a1.85 1.85 0 1 1 0-3.7h.06a1.5 1.5 0 0 0 1.37-.98 1.5 1.5 0 0 0-.3-1.65l-.05-.05a1.85 1.85 0 1 1 2.6-2.6l.05.05a1.5 1.5 0 0 0 1.65.3H9.4a1.5 1.5 0 0 0 .9-1.37V4a1.85 1.85 0 1 1 3.7 0v.06a1.5 1.5 0 0 0 .9 1.37 1.5 1.5 0 0 0 1.65-.3l.05-.05a1.85 1.85 0 1 1 2.6 2.6l-.05.05a1.5 1.5 0 0 0-.3 1.65v.06a1.5 1.5 0 0 0 1.37.9H20a1.85 1.85 0 1 1 0 3.7h-.06a1.5 1.5 0 0 0-1.37.9Z" />
  </svg>
);

const IconUser: IconRenderer = ({ className }) => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.7}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <circle cx="12" cy="8" r="3.8" />
    <path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
  </svg>
);

const RAIL_ITEMS: RailItem[] = [
  { id: "overview", label: "Overview", group: "main", icon: IconGrid },
  { id: "projects", label: "Projects", group: "main", icon: IconLayers },
  { id: "analytics", label: "Analytics", group: "main", icon: IconChart },
  { id: "messages", label: "Messages", group: "main", icon: IconChat, badge: 3 },
  { id: "schedule", label: "Schedule", group: "main", icon: IconCalendar },
  { id: "files", label: "Files", group: "main", icon: IconFolder },
  { id: "settings", label: "Settings", group: "system", icon: IconGear },
  { id: "account", label: "Account", group: "system", icon: IconUser },
];

const CONTENT: Record<string, SectionContent> = {
  overview: {
    kicker: "Workspace",
    title: "Overview",
    body: "A live read on everything moving through the workspace this week — sprints, review load, and who is around to unblock you.",
    highlights: [
      { label: "Active sprints", value: "4", note: "2 shipping this week" },
      { label: "Open tasks", value: "37", note: "12 waiting on review" },
      { label: "Team online", value: "9", note: "of 14 members" },
    ],
    meta: "Synced 4 minutes ago across 3 devices",
  },
  projects: {
    kicker: "Delivery",
    title: "Projects",
    body: "Every initiative across the three product squads, grouped by stage so blockers surface before they slip a release.",
    highlights: [
      { label: "In progress", value: "6", note: "Across 3 squads" },
      { label: "Blocked", value: "1", note: "Waiting on design" },
      { label: "Shipped in July", value: "11", note: "On or ahead of plan" },
    ],
    meta: "Roadmap last edited by Priya, 22 minutes ago",
  },
  analytics: {
    kicker: "Insights",
    title: "Analytics",
    body: "Product usage and performance signals pulled from the last seven days, benchmarked against the trailing week.",
    highlights: [
      { label: "Weekly active", value: "28.4k", note: "+6.2% vs last week" },
      { label: "Activation", value: "3.9%", note: "Signup to first value" },
      { label: "P95 latency", value: "212ms", note: "Down from 340ms" },
    ],
    meta: "Metrics refresh every 15 minutes from the warehouse",
  },
  messages: {
    kicker: "Inbox",
    title: "Messages",
    body: "Threads that need a reply from you, ranked by mentions first so nothing waiting on your call gets buried.",
    highlights: [
      { label: "Unread", value: "3", note: "2 mention you directly" },
      { label: "Open threads", value: "18", note: "Across 5 channels" },
      { label: "Median reply", value: "24m", note: "Fastest this quarter" },
    ],
    meta: "Notifications muted until 9:00 tomorrow",
  },
  schedule: {
    kicker: "Calendar",
    title: "Schedule",
    body: "Today's commitments plus the focus blocks you protected, so deep work does not get eaten by the meeting stack.",
    highlights: [
      { label: "Today", value: "3", note: "Next: standup at 10:00" },
      { label: "This week", value: "12", note: "4 still need prep" },
      { label: "Focus time", value: "6h", note: "Protected and blocked" },
    ],
    meta: "Timezone: Asia/Karachi (GMT+5)",
  },
  files: {
    kicker: "Library",
    title: "Files",
    body: "Shared documents and assets your team touched recently, with storage headroom so uploads never stall mid-review.",
    highlights: [
      { label: "Documents", value: "248", note: "18 updated today" },
      { label: "Storage", value: "64%", note: "of 50 GB in use" },
      { label: "Shared", value: "31", note: "with your team" },
    ],
    meta: "Version history retained for 90 days",
  },
  settings: {
    kicker: "Configuration",
    title: "Settings",
    body: "Workspace-wide controls — integrations, membership, and billing — everything that changes how the whole team works.",
    highlights: [
      { label: "Integrations", value: "7", note: "GitHub, Linear, Slack…" },
      { label: "Members", value: "14", note: "3 invites pending" },
      { label: "Plan", value: "Team", note: "Renews August 1" },
    ],
    meta: "Only owners can change billing and roles",
  },
  account: {
    kicker: "You",
    title: "Account",
    body: "Your personal profile, access level, and contribution streak — the record of what you have shipped so far.",
    highlights: [
      { label: "Role", value: "Maintainer", note: "Full workspace access" },
      { label: "Streak", value: "23 days", note: "A new personal best" },
      { label: "Reviews", value: "142", note: "Merged this quarter" },
    ],
    meta: "Signed in from Lahore · session expires in 6 days",
  },
};

export default function SideRail() {
  const reduce = useReducedMotion();
  const uid = useId();
  const [activeId, setActiveId] = useState<string>("overview");
  const [focusIndex, setFocusIndex] = useState<number>(0);
  const [hoverId, setHoverId] = useState<string | null>(null);
  const [focusId, setFocusId] = useState<string | null>(null);
  const [dismissedTip, setDismissedTip] = useState<string | null>(null);
  const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const mainItems = useMemo(
    () => RAIL_ITEMS.filter((item) => item.group === "main"),
    [],
  );
  const systemItems = useMemo(
    () => RAIL_ITEMS.filter((item) => item.group === "system"),
    [],
  );

  const active = CONTENT[activeId];

  function moveFocus(next: number) {
    const count = RAIL_ITEMS.length;
    const wrapped = (next + count) % count;
    setFocusIndex(wrapped);
    setDismissedTip(null);
    btnRefs.current[wrapped]?.focus();
  }

  function handleKeyDown(
    event: KeyboardEvent<HTMLButtonElement>,
    index: number,
  ) {
    switch (event.key) {
      case "ArrowDown":
      case "ArrowRight":
        event.preventDefault();
        moveFocus(index + 1);
        break;
      case "ArrowUp":
      case "ArrowLeft":
        event.preventDefault();
        moveFocus(index - 1);
        break;
      case "Home":
        event.preventDefault();
        moveFocus(0);
        break;
      case "End":
        event.preventDefault();
        moveFocus(RAIL_ITEMS.length - 1);
        break;
      case "Escape":
        if (focusId) {
          event.preventDefault();
          setDismissedTip(focusId);
        }
        break;
      default:
        break;
    }
  }

  function renderTab(item: RailItem) {
    const index = RAIL_ITEMS.indexOf(item);
    const Icon = item.icon;
    const isActive = activeId === item.id;
    const tipVisible =
      hoverId === item.id ||
      (focusId === item.id && dismissedTip !== item.id);

    return (
      <div key={item.id} className="relative flex items-center justify-center">
        <button
          ref={(el) => {
            btnRefs.current[index] = el;
          }}
          type="button"
          role="tab"
          id={`${uid}-tab-${item.id}`}
          aria-selected={isActive}
          aria-controls={`${uid}-panel-${item.id}`}
          aria-label={item.label}
          tabIndex={focusIndex === index ? 0 : -1}
          onClick={() => {
            setActiveId(item.id);
            setFocusIndex(index);
          }}
          onKeyDown={(event) => handleKeyDown(event, index)}
          onMouseEnter={() => setHoverId(item.id)}
          onMouseLeave={() =>
            setHoverId((current) => (current === item.id ? null : current))
          }
          onFocus={() => {
            setFocusId(item.id);
            setFocusIndex(index);
            setDismissedTip(null);
          }}
          onBlur={() =>
            setFocusId((current) => (current === item.id ? null : current))
          }
          className={[
            "group relative flex h-11 w-11 items-center justify-center rounded-xl outline-none transition-colors duration-200",
            "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
            isActive
              ? "bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300"
              : "text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100",
          ].join(" ")}
        >
          {isActive ? (
            <motion.span
              layoutId={`${uid}-indicator`}
              aria-hidden="true"
              className="absolute -left-[7px] top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-full bg-indigo-500 dark:bg-indigo-400"
              transition={
                reduce
                  ? { duration: 0 }
                  : { type: "spring", stiffness: 520, damping: 40 }
              }
            />
          ) : null}

          <Icon className="h-[22px] w-[22px]" />

          {item.badge ? (
            <span
              aria-hidden="true"
              className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center"
            >
              <span className="siderail-ping absolute inline-flex h-full w-full rounded-full bg-rose-400/60" />
              <span className="relative inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-semibold leading-none text-white">
                {item.badge}
              </span>
            </span>
          ) : null}
        </button>

        <AnimatePresence>
          {tipVisible ? (
            <motion.span
              aria-hidden="true"
              initial={reduce ? { opacity: 0 } : { opacity: 0, x: -6 }}
              animate={{ opacity: 1, x: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, x: -6 }}
              transition={{ duration: reduce ? 0.12 : 0.16, ease: "easeOut" }}
              className="pointer-events-none absolute left-full top-1/2 z-30 ml-3 flex -translate-y-1/2 items-center whitespace-nowrap rounded-lg bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-white shadow-lg shadow-slate-900/25 dark:bg-slate-100 dark:text-slate-900"
            >
              <span className="absolute -left-1 top-1/2 h-2 w-2 -translate-y-1/2 rotate-45 rounded-[2px] bg-slate-900 dark:bg-slate-100" />
              {item.label}
              {item.badge ? (
                <span className="ml-1.5 rounded-full bg-white/15 px-1.5 py-px text-[10px] text-white dark:bg-slate-900/10 dark:text-slate-700">
                  {item.badge} new
                </span>
              ) : null}
            </motion.span>
          ) : null}
        </AnimatePresence>
      </div>
    );
  }

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 sm:px-6 lg:py-24 dark:bg-slate-950">
      <style>{`
        @keyframes siderail-ping {
          0% { transform: scale(1); opacity: 0.7; }
          75%, 100% { transform: scale(2.1); opacity: 0; }
        }
        .siderail-ping { animation: siderail-ping 1.9s cubic-bezier(0,0,0.2,1) infinite; }
        @keyframes siderail-drift {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-3px); }
        }
        .siderail-drift { animation: siderail-drift 4.5s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .siderail-ping, .siderail-drift { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -right-24 top-0 h-72 w-72 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-500/15"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="mb-8 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Navigation rail
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            A compact rail that stays out of the way
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Icon-only navigation with tooltips on hover and focus. Tab into the
            rail, then use{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
              ↑
            </kbd>{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
              ↓
            </kbd>{" "}
            to move,{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
              Enter
            </kbd>{" "}
            to open, and{" "}
            <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
              Esc
            </kbd>{" "}
            to dismiss a tooltip.
          </p>
        </header>

        <MotionConfig reducedMotion="user">
          <div className="flex overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900">
            <div className="flex w-[76px] shrink-0 flex-col items-center border-r border-slate-200 bg-white py-5 dark:border-slate-800 dark:bg-slate-900">
              <div className="siderail-drift flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-lg shadow-indigo-500/30">
                <IconBolt className="h-5 w-5" />
              </div>

              <div
                aria-hidden="true"
                className="my-4 h-px w-8 bg-slate-200 dark:bg-slate-800"
              />

              <div
                role="tablist"
                aria-orientation="vertical"
                aria-label="Workspace sections"
                className="flex flex-1 flex-col items-center gap-1.5"
              >
                {mainItems.map(renderTab)}

                <div aria-hidden="true" className="min-h-6 flex-1" />

                {systemItems.map(renderTab)}
              </div>
            </div>

            <div className="min-w-0 flex-1 p-6 sm:p-8 lg:p-10">
              <AnimatePresence mode="wait">
                <motion.div
                  key={activeId}
                  role="tabpanel"
                  id={`${uid}-panel-${activeId}`}
                  aria-labelledby={`${uid}-tab-${activeId}`}
                  tabIndex={0}
                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10 }}
                  transition={{ duration: 0.22, ease: "easeOut" }}
                  className="outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                >
                  <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
                    {active.kicker}
                  </p>
                  <h3 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
                    {active.title}
                  </h3>
                  <p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                    {active.body}
                  </p>

                  <dl className="mt-7 grid grid-cols-1 gap-3 sm:grid-cols-3">
                    {active.highlights.map((h) => (
                      <div
                        key={h.label}
                        className="rounded-2xl border border-slate-200 bg-slate-50/60 p-4 dark:border-slate-800 dark:bg-slate-800/40"
                      >
                        <dt className="text-xs font-medium text-slate-500 dark:text-slate-400">
                          {h.label}
                        </dt>
                        <dd className="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50">
                          {h.value}
                        </dd>
                        <dd className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                          {h.note}
                        </dd>
                      </div>
                    ))}
                  </dl>

                  <div className="mt-7 flex items-center gap-2 border-t border-slate-200 pt-4 text-xs text-slate-400 dark:border-slate-800 dark:text-slate-500">
                    <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
                    {active.meta}
                  </div>
                </motion.div>
              </AnimatePresence>
            </div>
          </div>
        </MotionConfig>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →