Web InnoventixFreeCode

Icons Sidebar

Original · free

icon sidebar with labels on hover

byWeb InnoventixReact + Tailwind
sideiconssidebars
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-icons.json
side-icons.tsx
"use client";

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

type IconProps = { className?: string };

type NavItem = {
  id: string;
  label: string;
  hint: string;
  body: string;
  badge?: number;
  icon: (props: IconProps) => ReactNode;
};

function DashboardIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3" y="3" width="7.5" height="7.5" rx="1.6" />
      <rect x="13.5" y="3" width="7.5" height="7.5" rx="1.6" />
      <rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.6" />
      <rect x="3" y="13.5" width="7.5" height="7.5" rx="1.6" />
    </svg>
  );
}

function AnalyticsIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M3.5 21h17" />
      <rect x="5" y="11" width="3" height="7" rx="1" />
      <rect x="10.5" y="6" width="3" height="12" rx="1" />
      <rect x="16" y="9" width="3" height="9" rx="1" />
    </svg>
  );
}

function ProjectsIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M12 3 3 7.6l9 4.6 9-4.6L12 3Z" />
      <path d="m3 12.4 9 4.6 9-4.6" />
      <path d="m3 16.8 9 4.6 9-4.6" />
    </svg>
  );
}

function MessagesIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="M20.5 12a8 8 0 0 1-11.6 7.13L4 20.5l1.4-4.86A8 8 0 1 1 20.5 12Z" />
      <path d="M9 11h6" />
      <path d="M9 14.5h4" />
    </svg>
  );
}

function CalendarIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <rect x="3.2" y="4.6" width="17.6" height="16.2" rx="2.4" />
      <path d="M3.2 9.4h17.6" />
      <path d="M8 3v3.2" />
      <path d="M16 3v3.2" />
    </svg>
  );
}

function TeamIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="9" cy="8" r="3.3" />
      <path d="M3.4 20a5.6 5.6 0 0 1 11.2 0" />
      <path d="M16 5.2a3.3 3.3 0 0 1 0 5.6" />
      <path d="M17.6 14.5A5.6 5.6 0 0 1 20.6 20" />
    </svg>
  );
}

function SettingsIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <circle cx="12" cy="12" r="3.1" />
      <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
    </svg>
  );
}

function ChevronIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
      <path d="m9 6 6 6-6 6" />
    </svg>
  );
}

const NAV_ITEMS: NavItem[] = [
  {
    id: "dashboard",
    label: "Dashboard",
    hint: "Your workspace at a glance",
    body: "A single view of everything moving across your workspace today — active sprints, open tickets and the three metrics you check every morning.",
    icon: DashboardIcon,
  },
  {
    id: "analytics",
    label: "Analytics",
    hint: "Trends for the last 30 days",
    body: "Traffic, activation and retention curves for the last 30 days, broken down by segment so you can see exactly where growth is coming from.",
    icon: AnalyticsIcon,
  },
  {
    id: "projects",
    label: "Projects",
    hint: "Everything in flight",
    body: "Every build in flight with its owner, stage and ship date — filter by squad, drag to reprioritise and spot blockers before they slip.",
    icon: ProjectsIcon,
  },
  {
    id: "messages",
    label: "Messages",
    hint: "3 unread threads",
    body: "Threads from your team and customers, newest first. Reply inline, assign an owner or snooze anything you can't get to before Thursday.",
    badge: 3,
    icon: MessagesIcon,
  },
  {
    id: "calendar",
    label: "Calendar",
    hint: "This week's schedule",
    body: "Standups, design reviews and the Friday launch all in one week view. Drag events to reschedule and hold focus blocks that nobody can book over.",
    icon: CalendarIcon,
  },
  {
    id: "team",
    label: "Team",
    hint: "People and availability",
    body: "People, roles and current availability across the organisation. Invite a teammate, adjust permissions and see who's out before you assign work.",
    icon: TeamIcon,
  },
  {
    id: "settings",
    label: "Settings",
    hint: "Preferences and billing",
    body: "Workspace preferences, billing and access controls. Rotate API keys, manage seats and set the defaults every new project inherits.",
    icon: SettingsIcon,
  },
];

export default function SideIcons() {
  const prefersReduced = useReducedMotion();
  const uid = useId();
  const navId = `sideicn-nav-${uid}`;

  const [activeId, setActiveId] = useState<string>(NAV_ITEMS[0].id);
  const [focusedIndex, setFocusedIndex] = useState<number>(0);
  const [revealedId, setRevealedId] = useState<string | null>(null);
  const [expanded, setExpanded] = useState<boolean>(false);

  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const activeItem =
    NAV_ITEMS.find((item) => item.id === activeId) ?? NAV_ITEMS[0];

  const moveFocus = (index: number) => {
    setFocusedIndex(index);
    itemRefs.current[index]?.focus();
  };

  const onItemKeyDown = (
    event: KeyboardEvent<HTMLButtonElement>,
    index: number,
  ) => {
    const count = NAV_ITEMS.length;
    let next = index;
    switch (event.key) {
      case "ArrowDown":
        next = (index + 1) % count;
        break;
      case "ArrowUp":
        next = (index - 1 + count) % count;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = count - 1;
        break;
      default:
        return;
    }
    event.preventDefault();
    moveFocus(next);
  };

  const railTransition = prefersReduced
    ? { duration: 0 }
    : {
        type: "tween" as const,
        duration: 0.32,
        ease: [0.22, 1, 0.36, 1] as [number, number, number, number],
      };

  const tooltipMotion = prefersReduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        initial: { opacity: 0, x: -6, scale: 0.96 },
        animate: { opacity: 1, x: 0, scale: 1 },
        exit: { opacity: 0, x: -6, scale: 0.96 },
      };

  return (
    <section className="relative w-full bg-neutral-100 px-4 py-16 dark:bg-neutral-950 sm:px-6 sm:py-24">
      <style>{`
        @keyframes sideicn-glow {
          0%, 100% { opacity: 1; }
          50% { opacity: 0.5; }
        }
        @keyframes sideicn-blip {
          0%, 100% { opacity: 0.9; transform: scale(1); }
          50% { opacity: 0.4; transform: scale(1.35); }
        }
        .sideicn-bar { animation: sideicn-glow 2.6s ease-in-out infinite; }
        .sideicn-dot { animation: sideicn-blip 1.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .sideicn-bar, .sideicn-dot { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-8 max-w-xl">
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
            Workspace navigation
          </p>
          <h2 className="mt-2 text-2xl font-semibold tracking-tight text-neutral-900 dark:text-neutral-50 sm:text-3xl">
            An icon rail that speaks up on hover
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
            Hover or focus any icon to reveal its label. Use the arrow keys to
            move between items, or pin the rail open to keep every label in view.
          </p>
        </header>

        <div className="flex min-h-[30rem] overflow-hidden rounded-2xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900">
          {/* Sidebar rail */}
          <motion.nav
            aria-label="Primary"
            initial={false}
            animate={{ width: expanded ? 244 : 76 }}
            transition={railTransition}
            className="relative flex shrink-0 flex-col border-r border-neutral-200 bg-neutral-50/80 dark:border-neutral-800 dark:bg-neutral-950/40"
          >
            {/* Brand */}
            <div className="flex h-16 items-center gap-3 px-4">
              <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-sm">
                <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M13 2 4.5 13H11l-1 9 8.5-11H12l1-9Z" />
                </svg>
              </span>
              <AnimatePresence initial={false}>
                {expanded ? (
                  <motion.span
                    key="brand"
                    initial={prefersReduced ? { opacity: 0 } : { opacity: 0, x: -4 }}
                    animate={{ opacity: 1, x: 0 }}
                    exit={prefersReduced ? { opacity: 0 } : { opacity: 0, x: -4 }}
                    transition={{ duration: prefersReduced ? 0 : 0.18 }}
                    className="truncate text-sm font-semibold tracking-tight text-neutral-900 dark:text-neutral-50"
                  >
                    Halyard
                  </motion.span>
                ) : null}
              </AnimatePresence>
            </div>

            {/* Items */}
            <ul role="list" className="flex flex-1 flex-col gap-1 px-3 py-2">
              {NAV_ITEMS.map((item, index) => {
                const isActive = item.id === activeId;
                const isRevealed = revealedId === item.id && !expanded;
                const Icon = item.icon;
                return (
                  <li key={item.id} className="relative">
                    <button
                      ref={(node) => {
                        itemRefs.current[index] = node;
                      }}
                      type="button"
                      aria-label={item.label}
                      aria-current={isActive ? "page" : undefined}
                      tabIndex={focusedIndex === index ? 0 : -1}
                      onClick={() => setActiveId(item.id)}
                      onKeyDown={(event) => onItemKeyDown(event, index)}
                      onFocus={() => {
                        setFocusedIndex(index);
                        setRevealedId(item.id);
                      }}
                      onBlur={() =>
                        setRevealedId((current) =>
                          current === item.id ? null : current,
                        )
                      }
                      onMouseEnter={() => setRevealedId(item.id)}
                      onMouseLeave={() =>
                        setRevealedId((current) =>
                          current === item.id ? null : current,
                        )
                      }
                      className={[
                        "group/item relative flex h-11 w-full items-center rounded-xl outline-none transition-colors",
                        expanded ? "gap-3 px-3" : "justify-center px-0",
                        isActive
                          ? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300"
                          : "text-neutral-500 hover:bg-neutral-200/60 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-100",
                        "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950",
                      ].join(" ")}
                    >
                      {/* Active indicator bar (slides between items) */}
                      {isActive ? (
                        <motion.span
                          layoutId={`sideicn-indicator-${uid}`}
                          layout={!prefersReduced}
                          transition={railTransition}
                          className="sideicn-bar pointer-events-none absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-indigo-600 dark:bg-indigo-400"
                          aria-hidden="true"
                        />
                      ) : null}

                      <span className="relative flex h-6 w-6 shrink-0 items-center justify-center">
                        <Icon className="h-[22px] w-[22px]" />
                        {item.badge && !expanded ? (
                          <span
                            className="sideicn-dot absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full bg-rose-500 ring-2 ring-neutral-50 dark:ring-neutral-950"
                            aria-hidden="true"
                          />
                        ) : null}
                      </span>

                      {/* Inline label (expanded mode) */}
                      {expanded ? (
                        <motion.span
                          initial={prefersReduced ? { opacity: 0 } : { opacity: 0, x: -4 }}
                          animate={{ opacity: 1, x: 0 }}
                          transition={{ duration: prefersReduced ? 0 : 0.18 }}
                          className="flex min-w-0 flex-1 items-center justify-between gap-2 text-sm font-medium"
                        >
                          <span className="truncate">{item.label}</span>
                          {item.badge ? (
                            <span className="flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[11px] font-semibold text-white">
                              {item.badge}
                            </span>
                          ) : null}
                        </motion.span>
                      ) : null}
                    </button>

                    {/* Hover / focus tooltip (collapsed mode) */}
                    <AnimatePresence>
                      {isRevealed ? (
                        <motion.span
                          role="tooltip"
                          {...tooltipMotion}
                          transition={{ duration: prefersReduced ? 0 : 0.16, ease: "easeOut" }}
                          className="pointer-events-none absolute left-full top-1/2 z-20 ml-3 flex -translate-y-1/2 flex-col rounded-lg bg-neutral-900 px-3 py-2 text-left shadow-lg ring-1 ring-black/5 dark:bg-neutral-800 dark:ring-white/10"
                        >
                          <span className="flex items-center gap-2 whitespace-nowrap text-[13px] font-semibold text-white">
                            {item.label}
                            {item.badge ? (
                              <span className="flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-semibold text-white">
                                {item.badge}
                              </span>
                            ) : null}
                          </span>
                          <span className="whitespace-nowrap text-[11px] text-neutral-300 dark:text-neutral-400">
                            {item.hint}
                          </span>
                          <span
                            className="absolute right-full top-1/2 -mr-px h-2 w-2 -translate-y-1/2 rotate-45 rounded-[1px] bg-neutral-900 dark:bg-neutral-800"
                            aria-hidden="true"
                          />
                        </motion.span>
                      ) : null}
                    </AnimatePresence>
                  </li>
                );
              })}
            </ul>

            {/* Footer: user + collapse toggle */}
            <div className="border-t border-neutral-200 px-3 py-3 dark:border-neutral-800">
              <div className={["flex items-center", expanded ? "gap-3 px-1" : "justify-center"].join(" ")}>
                <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-sky-500 text-xs font-bold text-white">
                  SK
                </span>
                {expanded ? (
                  <motion.span
                    initial={prefersReduced ? { opacity: 0 } : { opacity: 0, x: -4 }}
                    animate={{ opacity: 1, x: 0 }}
                    transition={{ duration: prefersReduced ? 0 : 0.18 }}
                    className="flex min-w-0 flex-col leading-tight"
                  >
                    <span className="truncate text-sm font-medium text-neutral-900 dark:text-neutral-50">
                      Salma Khan
                    </span>
                    <span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
                      Product lead
                    </span>
                  </motion.span>
                ) : null}
              </div>

              <button
                type="button"
                onClick={() => setExpanded((value) => !value)}
                aria-expanded={expanded}
                aria-controls={navId}
                aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
                className={[
                  "mt-3 flex h-9 items-center rounded-lg text-neutral-500 outline-none transition-colors",
                  expanded ? "w-full gap-2 px-3" : "w-full justify-center",
                  "hover:bg-neutral-200/60 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-100",
                  "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50 dark:focus-visible:ring-offset-neutral-950",
                ].join(" ")}
              >
                <ChevronIcon
                  className={[
                    "h-5 w-5 shrink-0 transition-transform duration-300",
                    expanded ? "rotate-180" : "rotate-0",
                  ].join(" ")}
                />
                {expanded ? (
                  <span className="text-sm font-medium">Collapse</span>
                ) : null}
              </button>
            </div>

            {/* Referenced by the toggle for aria-controls */}
            <span id={navId} className="sr-only">
              Primary navigation, {expanded ? "expanded" : "collapsed"}
            </span>
          </motion.nav>

          {/* Content panel reflects the active item */}
          <div className="min-w-0 flex-1 bg-white p-6 dark:bg-neutral-900 sm:p-10">
            <AnimatePresence mode="wait">
              <motion.div
                key={activeItem.id}
                initial={prefersReduced ? { opacity: 0 } : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={prefersReduced ? { opacity: 0 } : { opacity: 0, y: -8 }}
                transition={{ duration: prefersReduced ? 0 : 0.24, ease: "easeOut" }}
              >
                <div className="flex items-center gap-3">
                  <span className="flex h-11 w-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
                    <activeItem.icon className="h-6 w-6" />
                  </span>
                  <div>
                    <h3 className="text-lg font-semibold tracking-tight text-neutral-900 dark:text-neutral-50">
                      {activeItem.label}
                    </h3>
                    <p className="text-sm text-neutral-500 dark:text-neutral-400">
                      {activeItem.hint}
                    </p>
                  </div>
                </div>

                <p className="mt-6 max-w-lg text-[15px] leading-relaxed text-neutral-700 dark:text-neutral-300">
                  {activeItem.body}
                </p>

                <div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-3">
                  {["Owner", "Updated", "Status"].map((label, i) => (
                    <div
                      key={label}
                      className="rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-neutral-800 dark:bg-neutral-950/40"
                    >
                      <p className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
                        {label}
                      </p>
                      <p className="mt-1 text-sm font-semibold text-neutral-800 dark:text-neutral-200">
                        {["You", "2m ago", "On track"][i]}
                      </p>
                    </div>
                  ))}
                </div>
              </motion.div>
            </AnimatePresence>
          </div>
        </div>

        <p className="mt-4 text-xs text-neutral-500 dark:text-neutral-500">
          Tip: focus a rail icon and press{" "}
          <kbd className="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
            ↑
          </kbd>{" "}
          <kbd className="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
            ↓
          </kbd>{" "}
          to move, then{" "}
          <kbd className="rounded border border-neutral-300 bg-neutral-100 px-1.5 py-0.5 font-mono text-[10px] text-neutral-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
            Enter
          </kbd>{" "}
          to open.
        </p>
      </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 →