Skip to main content
Lumidot is built with accessibility in mind, providing automatic support for reduced motion preferences and proper ARIA attributes.

Prefers-Reduced-Motion Support

Lumidot automatically detects and responds to the user’s prefers-reduced-motion system preference using the useReducedMotion hook.

The useReducedMotion Hook

Implemented in index.tsx:226-239:
function useReducedMotion(enabled: boolean): boolean {
  const [reduced, setReduced] = React.useState(false);

  React.useEffect(() => {
    if (!enabled) return;
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReduced(mq.matches);
    const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, [enabled]);

  return reduced;
}

How It Works

  1. Media Query Listening: Uses window.matchMedia to check the system preference
  2. Dynamic Updates: Listens for changes and updates in real-time
  3. Conditional Activation: Only enabled for sequence mode animations (enabled parameter)

When Reduced Motion Is Active

The hook is only used for sequence mode patterns:
const reduced = useReducedMotion(isSequence);
Sequence mode includes patterns like:
  • corners-only
  • plus-hollow
  • spiral

Behavior with Reduced Motion

When prefers-reduced-motion: reduce is detected:

Sequence Mode

All frames display simultaneously instead of animating:
React.useEffect(() => {
  if (!isSequence) return;
  setFrame(0);
  if (frames.length <= 1 || reduced) return; // Skip interval
  const id = window.setInterval(() => setFrame((prev) => (prev + 1) % frames.length), 1250);
  return () => window.clearInterval(id);
}, [frames, reduced, isSequence]);
Active dots calculation:
const active = React.useMemo(() => {
  if (!isSequence) return new Set<number>(frames[0]);
  if (reduced || frames.length <= 1) return allDots; // Show all dots
  return new Set<number>(frames[frame % frames.length]);
}, [isSequence, frames, reduced, frame, allDots]);
Transitions are disabled:
transition:
  frames.length > 1 && !reduced
    ? `opacity ${on ? fadeIn : 250}ms ${on ? 'ease-out' : 'ease-in'}, transform 250ms ease`
    : undefined,

Wave Mode

Wave mode patterns continue to display normally, as CSS animations are considered less disruptive than JavaScript-driven frame sequences.

Testing Reduced Motion

  1. Open System Preferences
  2. Go to Accessibility → Display
  3. Enable “Reduce motion”

Example: Testing Reduced Motion

import { Lumidot } from 'lumidot';

// This will respect prefers-reduced-motion
// When enabled, all 4 corners show simultaneously
<Lumidot pattern="corners-only" rows={4} cols={4} variant="blue" />

// This will continue animating normally
// Wave mode is not affected by the hook
<Lumidot pattern="wave-lr" rows={3} cols={5} variant="emerald" />

ARIA Attributes

Lumidot includes proper ARIA attributes for screen reader compatibility.

Role and Label

Every Lumidot component has:
role="status"
aria-label="Loading"
Implemented in index.tsx:350-354:
return (
  <span
    ref={ref}
    role="status"
    aria-label="Loading"
    data-lumidot=""
    // ...
  >

What This Means

  • role="status": Indicates this is a status update region
  • aria-label="Loading": Provides a text description for screen readers
Screen readers will announce “Loading” when encountering the component.

Custom Labels

While Lumidot doesn’t expose an aria-label prop, you can wrap it in a container with a custom label:
<div aria-label="Processing your request">
  <Lumidot pattern="wave-lr" variant="blue" />
</div>
Or use a visually hidden label:
<>
  <span className="sr-only">Loading data...</span>
  <Lumidot pattern="spiral" variant="purple" />
</>

Data Attributes

Lumidot adds semantic data attributes for debugging and styling:
data-lumidot=""
data-lumidot-pattern={pattern}
data-lumidot-variant={variant}
data-lumidot-rows={rows}
data-lumidot-cols={cols}
data-lumidot-direction={direction}
data-lumidot-mode={isSequence ? 'sequence' : 'wave'}
These can be used for:

Custom Styling

/* Style all Lumidot components */
[data-lumidot] {
  margin: 1rem;
}

/* Target specific patterns */
[data-lumidot-pattern="wave-lr"] {
  border: 1px solid #ccc;
}

/* Target by mode */
[data-lumidot-mode="sequence"] {
  padding: 0.5rem;
}

Testing

const loader = screen.getByTestId('my-loader');
expect(loader).toHaveAttribute('data-lumidot-mode', 'wave');
expect(loader).toHaveAttribute('data-lumidot-pattern', 'all');

Test ID Support

Lumidot accepts a testId prop for testing:
<Lumidot testId="login-loader" pattern="spiral" variant="blue" />
Rendered as:
<span data-testid="login-loader" ...>
  <!-- dots -->
</span>

Testing Example

import { render, screen } from '@testing-library/react';
import { Lumidot } from 'lumidot';

test('renders loader with correct pattern', () => {
  render(<Lumidot testId="loader" pattern="wave-lr" variant="emerald" />);
  
  const loader = screen.getByTestId('loader');
  expect(loader).toBeInTheDocument();
  expect(loader).toHaveAttribute('data-lumidot-pattern', 'wave-lr');
  expect(loader).toHaveAttribute('data-lumidot-variant', 'emerald');
  expect(loader).toHaveAttribute('role', 'status');
  expect(loader).toHaveAttribute('aria-label', 'Loading');
});

Dot-Level Data Attributes

Each individual dot has data attributes:
<span 
  key={i} 
  data-lumidot-dot="" 
  data-lumidot-dot-active={active.has(i)} 
  style={dotStyles[i]} 
/>

Usage

/* Style all dots */
[data-lumidot-dot] {
  border-radius: 50%;
}

/* Style only active dots */
[data-lumidot-dot-active="true"] {
  outline: 1px solid rgba(255, 255, 255, 0.3);
}

Semantic HTML

Lumidot uses semantic inline elements:
  • Container: <span> (inline element)
  • Dots: <span> (inline elements)
This ensures:
  • Proper document flow
  • No layout disruption
  • Screen reader compatibility

Color Contrast

Lumidot colors are vibrant but may need contrast considerations:

Dark Backgrounds

Most variants work well on dark backgrounds:
<div style={{ background: '#1a1a1a', padding: '2rem' }}>
  <Lumidot variant="blue" pattern="wave-lr" />
  <Lumidot variant="emerald" pattern="spiral" />
  <Lumidot variant="fuchsia" pattern="corners-sync" />
</div>

Light Backgrounds

Use darker variants or increase glow for visibility:
<div style={{ background: '#ffffff', padding: '2rem' }}>
  <Lumidot variant="indigo" glow={12} pattern="wave-lr" />
  <Lumidot variant="purple" glow={12} pattern="spiral" />
  <Lumidot variant="black" glow={8} pattern="all" />
</div>

Best Practices

1

Always provide context

Place loaders in context where their purpose is clear:
<button disabled>
  <Lumidot pattern="solo-center" variant="white" scale={0.6} />
  <span className="ml-2">Saving...</span>
</button>
2

Use appropriate patterns

Choose less intense patterns for sequence animations if many users have reduced motion enabled:
// Prefer wave mode for broader accessibility
<Lumidot pattern="wave-lr" variant="blue" />

// Over sequence mode
<Lumidot pattern="corners-only" variant="blue" />
3

Test with accessibility tools

  • Enable prefers-reduced-motion in your OS
  • Test with screen readers (NVDA, JAWS, VoiceOver)
  • Verify color contrast in your specific context
4

Consider timeout states

Provide alternative feedback if loading takes too long:
{isLoading && (
  <>
    <Lumidot pattern="wave-lr" variant="blue" />
    {isTimeout && <p>This is taking longer than expected...</p>}
  </>
)}

Summary

Reduced Motion

  • Automatic detection via useReducedMotion hook
  • Shows all frames simultaneously in sequence mode
  • Wave mode continues normally
  • Real-time system preference updates

ARIA Support

  • role="status" for screen readers
  • aria-label="Loading" announcement
  • Semantic HTML structure
  • Test ID support

Data Attributes

  • data-lumidot-pattern
  • data-lumidot-variant
  • data-lumidot-mode
  • data-lumidot-dot-active

Best Practices

  • Test with reduced motion enabled
  • Verify color contrast
  • Provide loading context
  • Use appropriate patterns

Build docs developers (and LLMs) love