Radix UILive

Roving Focus Group

Keyboard-navigable toolbar using Radix's roving focus pattern. Arrow keys move focus between items.

import * as RovingFocusGroup from "@radix-ui/react-roving-focus";
import { useState } from "react";

const tools = [
  { id: "bold", label: "B", title: "Bold" },
  { id: "italic", label: "I", title: "Italic" },
  { id: "underline", label: "U", title: "Underline" },
  { id: "strike", label: "S", title: "Strikethrough" },
  { id: "code", label: "<>", title: "Code" },
];

export function Toolbar() {
  const [active, setActive] = useState<string[]>([]);

  const toggle = (id: string) =>
    setActive((prev) =>
      prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
    );

  return (
    <RovingFocusGroup.Root
      orientation="horizontal"
      loop
      style={{
        display: "inline-flex",
        gap: 2,
        padding: 4,
        borderRadius: 8,
        border: "1px solid #e5e5e5",
        background: "#fafafa",
      }}
    >
      {tools.map((tool) => {
        const isActive = active.includes(tool.id);
        return (
          <RovingFocusGroup.Item key={tool.id} asChild>
            <button
              type="button"
              title={tool.title}
              onClick={() => toggle(tool.id)}
              style={{
                width: 36,
                height: 36,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 6,
                border: "none",
                cursor: "pointer",
                fontSize: 14,
                fontWeight: isActive ? 700 : 400,
                fontStyle: tool.id === "italic" ? "italic" : "normal",
                textDecoration:
                  tool.id === "underline"
                    ? "underline"
                    : tool.id === "strike"
                      ? "line-through"
                      : "none",
                background: isActive ? "#2563eb" : "transparent",
                color: isActive ? "#fff" : "#333",
                transition: "all 150ms ease",
                outline: "none",
              }}
              onFocus={(e) => {
                e.currentTarget.style.boxShadow = "0 0 0 2px #2563eb40";
              }}
              onBlur={(e) => {
                e.currentTarget.style.boxShadow = "none";
              }}
            >
              {tool.label}
            </button>
          </RovingFocusGroup.Item>
        );
      })}
    </RovingFocusGroup.Root>
  );
}

accessibilityfocuskeyboard