Claude Code for React Aria: Accessible Headless UI Components — Claude Skills 360 Blog
Blog / Frontend / Claude Code for React Aria: Accessible Headless UI Components
Frontend

Claude Code for React Aria: Accessible Headless UI Components

Published: March 18, 2027
Read time: 8 min read
By: Claude Skills 360

React Aria provides WAI-ARIA compliant behavior for React components — it handles keyboard navigation, focus management, screen reader announcements, and touch events so you only write the visual styles. useButton handles clicks, keyboard press, focus rings, and disabled states. useSelect + useListBox + useOption builds fully accessible dropdowns. useDialog + useModal manages focus trapping and aria-modal for overlays. useDatePicker + useCalendar + useCalendarCell composes accessible date pickers. useComboBox + useFilter builds autocomplete inputs. useTable with useTableRowSelection and useTableColumnHeader handles sortable, selectable data tables. React Aria Components (RAC) is the higher-level styled API on top. useLocale and I18nProvider handle internationalization. Claude Code generates React Aria hook compositions, accessible custom components, focus management patterns, and the complete keyboard interaction models for complex interactive widgets.

CLAUDE.md for React Aria

## React Aria Stack
- Version: react-aria >= 3.35, react-stately >= 3.33
- Hooks: @react-aria/button, @react-aria/select, @react-aria/dialog, @react-aria/focus
- State: useSelectState, useListState, useOverlayTriggerState from react-stately
- Focus: useFocusRing({ isTextInput? }) — returns isFocusVisible for ring styles
- I18n: <I18nProvider locale="en-US"> wraps app — useLocale() reads locale
- RAC: react-aria-components — higher level, already composed: Button, Select, Dialog
- Overlay: useOverlayTrigger + usePopover — positions and manages overlay lifecycle

Accessible Button

// components/ui/AccessibleButton.tsx
import { useButton, useFocusRing, mergeProps } from "react-aria"
import { useRef } from "react"
import type { AriaButtonProps } from "react-aria"

interface ButtonProps extends AriaButtonProps {
  variant?: "primary" | "secondary" | "ghost" | "danger"
  size?: "sm" | "md" | "lg"
  isLoading?: boolean
  children: React.ReactNode
}

export function AccessibleButton({
  variant = "primary",
  size = "md",
  isLoading,
  children,
  isDisabled,
  ...props
}: ButtonProps) {
  const ref = useRef<HTMLButtonElement>(null)
  const { buttonProps, isPressed } = useButton(
    { ...props, isDisabled: isDisabled || isLoading },
    ref
  )
  const { focusProps, isFocusVisible } = useFocusRing()

  const variantStyles = {
    primary: "bg-primary text-primary-foreground hover:bg-primary/90",
    secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
    ghost: "hover:bg-accent hover:text-accent-foreground",
    danger: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
  }

  const sizeStyles = {
    sm: "h-8 px-3 text-xs",
    md: "h-10 px-4 text-sm",
    lg: "h-12 px-6 text-base",
  }

  return (
    <button
      {...mergeProps(buttonProps, focusProps)}
      ref={ref}
      className={[
        "inline-flex items-center justify-center rounded-md font-medium transition-colors",
        "disabled:pointer-events-none disabled:opacity-50",
        variantStyles[variant],
        sizeStyles[size],
        isPressed && "scale-95",
        isFocusVisible && "ring-2 ring-ring ring-offset-2",
      ]
        .filter(Boolean)
        .join(" ")}
    >
      {isLoading ? (
        <>
          <svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
            <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
          </svg>
          <span className="sr-only">Loading</span>
          {children}
        </>
      ) : children}
    </button>
  )
}

Accessible Select

// components/ui/AccessibleSelect.tsx — fully accessible dropdown
import {
  useSelect,
  useListBox,
  useOption,
  usePopover,
  useOverlayPosition,
  DismissButton,
  mergeProps,
  HiddenSelect,
} from "react-aria"
import { useSelectState, type SelectStateOptions } from "react-stately"
import { useRef } from "react"
import { ChevronDown } from "lucide-react"
import type { Node } from "@react-types/shared"

interface SelectProps<T extends object> extends SelectStateOptions<T> {
  label: string
  className?: string
}

function Option<T extends object>({ item, state }: { item: Node<T>; state: ReturnType<typeof useSelectState<T>> }) {
  const ref = useRef<HTMLLIElement>(null)
  const { optionProps, isSelected, isFocused } = useOption({ key: item.key }, state, ref)
  const { focusProps, isFocusVisible } = useFocusRing()

  return (
    <li
      {...mergeProps(optionProps, focusProps)}
      ref={ref}
      className={[
        "px-3 py-2 text-sm cursor-default rounded-sm outline-none",
        isFocused && "bg-accent text-accent-foreground",
        isSelected && "font-medium",
        isFocusVisible && "ring-1 ring-ring",
      ]
        .filter(Boolean)
        .join(" ")}
    >
      <span className="flex items-center gap-2">
        {isSelected && (
          <svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
            <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
          </svg>
        )}
        <span className={isSelected ? "" : "ml-6"}>{item.rendered}</span>
      </span>
    </li>
  )
}

import { useFocusRing } from "react-aria"

export function AccessibleSelect<T extends object>({
  label,
  className,
  ...props
}: SelectProps<T>) {
  const state = useSelectState(props)
  const triggerRef = useRef<HTMLButtonElement>(null)
  const popoverRef = useRef<HTMLDivElement>(null)
  const listBoxRef = useRef<HTMLUListElement>(null)

  const { labelProps, triggerProps, valueProps, menuProps } = useSelect(props, state, triggerRef)

  const { overlayProps } = useOverlayPosition({
    targetRef: triggerRef,
    overlayRef: popoverRef,
    placement: "bottom start",
    offset: 4,
    isOpen: state.isOpen,
  })

  const { focusProps, isFocusVisible } = useFocusRing()

  return (
    <div className={className}>
      <label {...labelProps} className="text-sm font-medium text-foreground mb-1 block">
        {label}
      </label>

      {/* Hidden native select for form submission */}
      <HiddenSelect state={state} triggerRef={triggerRef} label={label} name={props.name} />

      <button
        {...mergeProps(triggerProps, focusProps)}
        ref={triggerRef}
        className={[
          "flex h-10 w-full items-center justify-between rounded-md border border-input",
          "bg-background px-3 py-2 text-sm ring-offset-background",
          "disabled:cursor-not-allowed disabled:opacity-50",
          isFocusVisible && "ring-2 ring-ring ring-offset-2",
        ]
          .filter(Boolean)
          .join(" ")}
      >
        <span {...valueProps}>
          {state.selectedItem ? state.selectedItem.rendered : "Select an option"}
        </span>
        <ChevronDown
          className={`h-4 w-4 text-muted-foreground transition-transform ${state.isOpen ? "rotate-180" : ""}`}
          aria-hidden="true"
        />
      </button>

      {state.isOpen && (
        <div
          {...overlayProps}
          ref={popoverRef}
          className="z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
        >
          <DismissButton onDismiss={() => state.close()} />
          <ul
            {...menuProps}
            ref={listBoxRef}
            className="p-1 max-h-60 overflow-auto outline-none"
          >
            {[...state.collection].map(item => (
              <Option key={item.key} item={item} state={state} />
            ))}
          </ul>
          <DismissButton onDismiss={() => state.close()} />
        </div>
      )}
    </div>
  )
}

Accessible Dialog / Modal

// components/ui/AccessibleDialog.tsx — focus trap + aria-modal
import {
  useDialog,
  useModal,
  useOverlay,
  usePreventScroll,
  FocusScope,
  mergeProps,
} from "react-aria"
import { useRef } from "react"

interface DialogProps {
  title: string
  isOpen: boolean
  onClose: () => void
  children: React.ReactNode
  isDismissable?: boolean
}

function DialogInner({ title, onClose, isDismissable = true, children }: Omit<DialogProps, "isOpen">) {
  const ref = useRef<HTMLDivElement>(null)

  const { overlayProps, underlayProps } = useOverlay(
    { isOpen: true, onClose, isDismissable, shouldCloseOnInteractOutside: () => isDismissable },
    ref
  )
  const { modalProps } = useModal()
  const { dialogProps, titleProps } = useDialog({}, ref)

  usePreventScroll()

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center p-4"
      {...underlayProps}
    >
      {/* Backdrop */}
      <div className="absolute inset-0 bg-black/50" aria-hidden="true" />

      <FocusScope contain restoreFocus autoFocus>
        <div
          {...mergeProps(overlayProps, dialogProps, modalProps)}
          ref={ref}
          className="relative z-10 w-full max-w-lg rounded-xl bg-background p-6 shadow-2xl"
        >
          <h2 {...titleProps} className="text-lg font-semibold mb-4">
            {title}
          </h2>
          {children}
          <button
            onClick={onClose}
            className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2"
            aria-label="Close dialog"
          >
            <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
              <path d="M18 6L6 18M6 6l12 12" />
            </svg>
          </button>
        </div>
      </FocusScope>
    </div>
  )
}

export function AccessibleDialog({ isOpen, ...props }: DialogProps) {
  if (!isOpen) return null
  return <DialogInner {...props} />
}

ComboBox / Autocomplete

// components/ui/AccessibleComboBox.tsx
import {
  useComboBox,
  useButton,
  useFilter,
  mergeProps,
} from "react-aria"
import { useComboBoxState } from "react-stately"
import { useRef } from "react"
import type { ComboBoxProps } from "@react-types/combobox"

export function AccessibleComboBox<T extends object>(props: ComboBoxProps<T> & { label: string }) {
  const { contains } = useFilter({ sensitivity: "base" })
  const state = useComboBoxState({ ...props, defaultFilter: contains })

  const inputRef = useRef<HTMLInputElement>(null)
  const listBoxRef = useRef<HTMLUListElement>(null)
  const popoverRef = useRef<HTMLDivElement>(null)
  const buttonRef = useRef<HTMLButtonElement>(null)

  const { buttonProps, inputProps, listBoxProps, labelProps } = useComboBox(
    { ...props, inputRef, listBoxRef, popoverRef, buttonRef },
    state
  )

  const { buttonProps: toggleProps } = useButton(buttonProps, buttonRef)

  return (
    <div className="relative">
      <label {...labelProps} className="text-sm font-medium block mb-1">
        {props.label}
      </label>
      <div className="flex">
        <input
          {...inputProps}
          ref={inputRef}
          className="flex h-10 w-full rounded-l-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
        />
        <button
          {...toggleProps}
          ref={buttonRef}
          className="rounded-r-md border border-l-0 border-input px-2 focus:ring-2 focus:ring-ring"
        >
          <svg className="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
            <path d="M4.427 9.573l3.396-3.396a.25.25 0 01.354 0l3.396 3.396A.25.25 0 0111.396 10H4.604a.25.25 0 01-.177-.427z"/>
          </svg>
        </button>
      </div>

      {state.isOpen && (
        <div
          ref={popoverRef}
          className="absolute z-50 w-full mt-1 rounded-md border bg-popover shadow-md"
        >
          <ul
            {...listBoxProps}
            ref={listBoxRef}
            className="max-h-60 overflow-auto p-1 outline-none"
          >
            {[...state.collection].map(item => (
              <li
                key={item.key}
                className={`px-3 py-2 text-sm rounded-sm cursor-default ${
                  state.selectionManager.focusedKey === item.key ? "bg-accent" : ""
                }`}
              >
                {item.rendered}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  )
}

For the Radix UI Primitives alternative that provides unstyled accessible components with a different API style — Radix uses compound components with Root/Trigger/Content pattern rather than hooks, so it integrates more naturally with CSS-in-JS styling without manual ref wiring, see the headless component guide. For the Headless UI alternative from Tailwind Labs that provides a smaller set of components (Listbox, Combobox, Dialog, Disclosure, Transition) with Tailwind-friendly styling patterns and simpler API than full React Aria hooks — useful for projects already using Tailwind, see the Headless UI patterns guide. The Claude Skills 360 bundle includes React Aria skill sets covering accessible dropdowns, dialogs, and date pickers. Start with the free tier to try accessibility-first component generation.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free