shadimage
indiacn
uiaccessibility-widgetsource: ok · refreshed

Accessibility Widget

Floating panel of reading adjustments that apply to the whole document and persist.

npx shadcn add "https://indiacn.in/r/accessibility-widget.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

https://indiacn.in/r/theme.jsonhttps://indiacn.in/r/typography.json

Files1 · source cached 10/8/2026, 3:41:22 AM

components/ui/accessibility-widget.tsx
'use client';

import {
  Accessibility,
  ALargeSmall,
  Baseline,
  Ban,
  Contrast,
  Link2,
  RotateCcw,
  Space,
  Type,
  X,
} from 'lucide-react';
import {
  ComponentProps,
  ReactNode,
  useCallback,
  useEffect,
  useState,
  useSyncExternalStore,
} from 'react';

import { Body2, Label1, Label2 } from '@/components/ui/typography';
import { cn } from '@/lib/utils';

/*
 * UX4G accessibility widget: a floating button opening a panel of reading
 * adjustments. Every setting is applied to the document root and remembered,
 * so it survives navigation.
 *
 * UX4G also lists a "Screen Reader" row. That is deliberately not shipped —
 * a browser-based text-to-speech toy is not a screen reader, and pretending
 * otherwise is worse for the people it claims to serve. Pass `extraRows` if
 * you have a real integration to offer.
 */
const STORAGE_KEY = 'indiacn-accessibility';

const STEP_MAX = 3;

interface IAccessibilitySettings {
  textStep: number;
  lineStep: number;
  spacingStep: number;
  highlightLinks: boolean;
  dyslexiaFont: boolean;
  hideImages: boolean;
  invertColors: boolean;
  readingMask: boolean;
}

const DEFAULT_SETTINGS: IAccessibilitySettings = {
  textStep: 0,
  lineStep: 0,
  spacingStep: 0,
  highlightLinks: false,
  dyslexiaFont: false,
  hideImages: false,
  invertColors: false,
  readingMask: false,
};

/*
 * Settings live in a module store read through useSyncExternalStore. Loading
 * them in an effect would set state during render-commit, and it would also
 * let two widgets on one page disagree.
 */
let currentSettings: IAccessibilitySettings = DEFAULT_SETTINGS;
const LISTENERS = new Set<() => void>();

if (typeof window !== 'undefined') {
  try {
    const saved = localStorage.getItem(STORAGE_KEY);
    if (saved) currentSettings = { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
  } catch {
    // Storage blocked or nothing saved; defaults stand.
  }
}

/** Subscribes a widget instance to setting changes. */
function subscribeSettings(listener: () => void) {
  LISTENERS.add(listener);
  return () => LISTENERS.delete(listener);
}

/** Current settings, for useSyncExternalStore. */
const getSettings = () => currentSettings;

/** Server render has no storage, so it always sees the defaults. */
const getServerSettings = () => DEFAULT_SETTINGS;

/** Replaces the settings and notifies every mounted widget. */
function writeSettings(next: IAccessibilitySettings) {
  currentSettings = next;
  applySettings(next);
  LISTENERS.forEach(listener => listener());
}

/**
 * Shared access to the reading settings.
 *
 * Exported so other components — the navbar's text-size control, for one — act
 * on the same store instead of keeping a second copy that disagrees with the
 * panel.
 */
function useAccessibilitySettings() {
  const settings = useSyncExternalStore(subscribeSettings, getSettings, getServerSettings);

  /*
   * Applied from a mount effect rather than at module scope. Doing it at
   * module scope tripped a bundler-level temporal dead zone
   * ("Cannot access 'k' before initialization") even with the declarations
   * ordered correctly, and a module side effect is not worth that fight.
   *
   * It lives on the hook, not on the panel, so the navbar's text-size control
   * works with no panel mounted — which was the original bug.
   */
  useEffect(() => {
    injectBehaviourStyles();
    applySettings(currentSettings);
  }, [settings]);

  const setStep = useCallback((key: 'textStep' | 'lineStep' | 'spacingStep', value: number) => {
    writeSettings({ ...currentSettings, [key]: Math.min(Math.max(value, 0), STEP_MAX) });
  }, []);

  /*
   * Reads the store at call time rather than from the render closure. Two
   * clicks in one tick both saw the same captured step and the second was
   * lost, so three presses landed on level 2.
   */
  const stepBy = useCallback((key: 'textStep' | 'lineStep' | 'spacingStep', delta: number) => {
    const next = Math.min(Math.max(currentSettings[key] + delta, 0), STEP_MAX);
    writeSettings({ ...currentSettings, [key]: next });
  }, []);

  const reset = useCallback(() => writeSettings(DEFAULT_SETTINGS), []);

  return { settings, setStep, stepBy, reset, maxStep: STEP_MAX };
}

/** Injected once. Registry consumers get the behaviour without extra CSS. */
const BEHAVIOUR_CSS = `
[data-a11y-highlight-links='true'] a {
  outline: 2px solid currentColor;
  outline-offset: 2px;
  text-decoration: underline !important;
}
[data-a11y-dyslexia='true'] * {
  font-family: 'Atkinson Hyperlegible', 'Comic Sans MS', Verdana, sans-serif !important;
  letter-spacing: 0.05em;
}
[data-a11y-hide-images='true'] :is(img, picture, video, svg:not([data-a11y-keep])) {
  visibility: hidden !important;
}
[data-a11y-invert='true'] {
  filter: invert(1) hue-rotate(180deg);
}
[data-a11y-invert='true'] :is(img, picture, video) {
  filter: invert(1) hue-rotate(180deg);
}
.indiacn-a11y-mask {
  position: fixed;
  inset-inline: 0;
  height: 8rem;
  pointer-events: none;
  z-index: 2147483000;
  box-shadow: 0 0 0 100vh rgb(0 0 0 / 0.6);
}
@media (prefers-reduced-motion: reduce) {
  .indiacn-a11y-mask { transition: none; }
}
`;

/** Adds the behaviour stylesheet to the document a single time. */
function injectBehaviourStyles() {
  const id = 'indiacn-a11y-styles';
  if (document.getElementById(id)) return;
  const style = document.createElement('style');
  style.id = id;
  style.textContent = BEHAVIOUR_CSS;
  document.head.appendChild(style);
}

/**
 * Writes settings onto the document root and remembers them.
 *
 * This belongs to the store, not to a component. It used to run in an effect
 * inside the panel, which meant the navbar's text-size control did nothing
 * unless a panel happened to be mounted somewhere on the page.
 */
function applySettings(settings: IAccessibilitySettings) {
  const root = document.documentElement;
  root.style.fontSize = settings.textStep ? `${100 + settings.textStep * 12.5}%` : '';
  root.style.lineHeight = settings.lineStep ? String(1.5 + settings.lineStep * 0.25) : '';
  root.style.letterSpacing = settings.spacingStep ? `${settings.spacingStep * 0.05}em` : '';
  root.dataset.a11yHighlightLinks = String(settings.highlightLinks);
  root.dataset.a11yDyslexia = String(settings.dyslexiaFont);
  root.dataset.a11yHideImages = String(settings.hideImages);
  root.dataset.a11yInvert = String(settings.invertColors);

  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
  } catch {
    // Private browsing and blocked storage are fine; the settings just do not persist.
  }
}

/** Horizontal reading band that follows the pointer. */
function ReadingMask({ active }: { active: boolean }) {
  const [top, setTop] = useState(0);

  useEffect(() => {
    if (!active) return;
    const move = (e: PointerEvent) => setTop(e.clientY - 64);
    window.addEventListener('pointermove', move);
    return () => window.removeEventListener('pointermove', move);
  }, [active]);

  if (!active) return null;
  return <div className='indiacn-a11y-mask' style={{ top }} aria-hidden />;
}

interface IRowProps {
  icon: ReactNode;
  label: string;
  children: ReactNode;
}

/** One panel row: icon, label, control. */
function AccessibilityRow({ icon, label, children }: IRowProps) {
  return (
    <div className='flex items-center gap-3 border-b border-neutral-100 px-4 py-3 last:border-b-0'>
      <div className='text-neutral flex size-8 shrink-0 items-center justify-center rounded-md bg-neutral-50 [&>svg]:size-4'>
        {icon}
      </div>
      <Label1 className='text-neutral flex-1'>{label}</Label1>
      {children}
    </div>
  );
}

interface IStepperButtonProps {
  label: string;
  step: number;
  onStep: () => void;
}

/** Cycles a stepped setting and reports where it is. */
function StepControl({ label, step, onStep }: IStepperButtonProps) {
  return (
    <button
      type='button'
      onClick={onStep}
      aria-label={`${label}. Level ${step} of ${STEP_MAX}`}
      className='bg-primary-100 text-primary-800 focus-visible:shadow-focus-primary flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-sm font-semibold transition-opacity hover:opacity-80 focus-visible:outline-none'
    >
      {step === 0 ? '+' : step}
    </button>
  );
}

interface IToggleControlProps {
  label: string;
  on: boolean;
  onToggle: () => void;
}

/** Small switch used by the boolean rows. */
function ToggleControl({ label, on, onToggle }: IToggleControlProps) {
  return (
    <button
      type='button'
      role='switch'
      aria-checked={on}
      aria-label={label}
      onClick={onToggle}
      className={cn(
        'focus-visible:shadow-focus-primary relative h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors focus-visible:outline-none',
        on ? 'bg-primary' : 'bg-neutral-200',
      )}
    >
      <div
        className={cn(
          'bg-neutral-0 absolute top-1 size-4 rounded-full transition-[left]',
          on ? 'left-6' : 'left-1',
        )}
      />
    </button>
  );
}

interface IAccessibilityWidgetProps extends Omit<ComponentProps<'div'>, 'children'> {
  /** Extra rows appended before the footer, for integrations we cannot ship. */
  extraRows?: ReactNode;
  title?: string;
}

/**
 * Floating accessibility panel with reading adjustments.
 *
 * Settings apply to the whole document and persist across pages.
 */
function AccessibilityWidget({
  className,
  extraRows,
  title = 'Accessibility options',
  ...props
}: IAccessibilityWidgetProps) {
  const [open, setOpen] = useState(false);
  const { settings } = useAccessibilitySettings();

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setOpen(false);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open]);

  const toggleOpen = useCallback(() => setOpen(o => !o), []);
  const close = useCallback(() => setOpen(false), []);
  const reset = useCallback(() => writeSettings(DEFAULT_SETTINGS), []);

  const bump = useCallback(
    (key: 'textStep' | 'lineStep' | 'spacingStep') =>
      writeSettings({ ...currentSettings, [key]: (currentSettings[key] + 1) % (STEP_MAX + 1) }),
    [],
  );
  const flip = useCallback(
    (key: keyof IAccessibilitySettings) =>
      writeSettings({ ...currentSettings, [key]: !currentSettings[key] }),
    [],
  );

  const bumpText = useCallback(() => bump('textStep'), [bump]);
  const bumpLine = useCallback(() => bump('lineStep'), [bump]);
  const bumpSpacing = useCallback(() => bump('spacingStep'), [bump]);
  const flipLinks = useCallback(() => flip('highlightLinks'), [flip]);
  const flipDyslexia = useCallback(() => flip('dyslexiaFont'), [flip]);
  const flipImages = useCallback(() => flip('hideImages'), [flip]);
  const flipInvert = useCallback(() => flip('invertColors'), [flip]);
  const flipMask = useCallback(() => flip('readingMask'), [flip]);

  return (
    <div className={cn('fixed right-5 bottom-5 z-50', className)} {...props}>
      <ReadingMask active={settings.readingMask} />

      {open && (
        <div
          role='dialog'
          aria-label={title}
          className='bg-neutral-0 absolute right-0 bottom-16 flex max-h-[70vh] w-[340px] flex-col overflow-hidden rounded-lg border border-neutral-100 shadow-lg'
        >
          <div className='bg-primary text-primary-foreground flex items-center justify-between px-4 py-3'>
            <Label1 className='font-semibold'>{title}</Label1>
            <button
              type='button'
              onClick={close}
              aria-label='Close accessibility options'
              className='focus-visible:shadow-focus-neutral cursor-pointer rounded-sm opacity-80 transition-opacity hover:opacity-100 focus-visible:outline-none'
            >
              <X className='size-5' aria-hidden />
            </button>
          </div>

          <div className='min-h-0 flex-1 overflow-y-auto'>
            <AccessibilityRow icon={<ALargeSmall />} label='Increase text size'>
              <StepControl label='Increase text size' step={settings.textStep} onStep={bumpText} />
            </AccessibilityRow>
            <AccessibilityRow icon={<Baseline />} label='Increase line height'>
              <StepControl
                label='Increase line height'
                step={settings.lineStep}
                onStep={bumpLine}
              />
            </AccessibilityRow>
            <AccessibilityRow icon={<Space />} label='Increase text spacing'>
              <StepControl
                label='Increase text spacing'
                step={settings.spacingStep}
                onStep={bumpSpacing}
              />
            </AccessibilityRow>
            <AccessibilityRow icon={<Link2 />} label='Highlight links'>
              <ToggleControl
                label='Highlight links'
                on={settings.highlightLinks}
                onToggle={flipLinks}
              />
            </AccessibilityRow>
            <AccessibilityRow icon={<Type />} label='Dyslexia friendly'>
              <ToggleControl
                label='Dyslexia friendly'
                on={settings.dyslexiaFont}
                onToggle={flipDyslexia}
              />
            </AccessibilityRow>
            <AccessibilityRow icon={<Ban />} label='Hide images'>
              <ToggleControl label='Hide images' on={settings.hideImages} onToggle={flipImages} />
            </AccessibilityRow>
            <AccessibilityRow icon={<Contrast />} label='Invert colours'>
              <ToggleControl
                label='Invert colours'
                on={settings.invertColors}
                onToggle={flipInvert}
              />
            </AccessibilityRow>
            <AccessibilityRow icon={<Accessibility />} label='Reading mask'>
              <ToggleControl label='Reading mask' on={settings.readingMask} onToggle={flipMask} />
            </AccessibilityRow>
            {extraRows}
          </div>

          <div className='flex items-center justify-between border-t border-neutral-100 px-4 py-3'>
            <button
              type='button'
              onClick={reset}
              aria-label='Reset accessibility settings'
              className='bg-primary-100 text-primary-800 focus-visible:shadow-focus-primary inline-flex cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 transition-opacity hover:opacity-80 focus-visible:outline-none'
            >
              <RotateCcw className='size-3.5' aria-hidden />
              <Label2>Reset</Label2>
            </button>
            <Body2 className='text-neutral-500'>Settings are remembered</Body2>
          </div>
        </div>
      )}

      <button
        type='button'
        onClick={toggleOpen}
        aria-expanded={open}
        aria-label={title}
        className='bg-primary text-primary-foreground focus-visible:shadow-focus-primary flex size-14 cursor-pointer items-center justify-center rounded-full shadow-lg transition-transform hover:scale-105 focus-visible:outline-none'
      >
        <Accessibility className='size-7' aria-hidden />
      </button>
    </div>
  );
}

export type { IAccessibilitySettings };

export { AccessibilityWidget, useAccessibilitySettings };

More from indiacn

Browse all components in this library →