Skip to navigation

Accessibility

zriz sets roles, keys and announcements. You write every word a screen reader says.

Label every control

zriz ships no words. Give each control a name in your language:

<Cropper.Area aria-label="Crop area" />
<Cropper.Dial aria-label="Straighten" />
<Cropper.Zoom aria-label="Zoom" />
<Cropper.AspectGroup aria-label="Aspect ratio">
  <Cropper.AspectItem value={1}>Square</Cropper.AspectItem>
</Cropper.AspectGroup>
<Cropper.Rotate aria-label="Rotate left">
  rotate
</Cropper.Rotate>

In development, Area, Dial and Zoom warn once each in the console when nothing names them. An aria-label, an aria-labelledby or a title counts. Text inside does not, because it never names a slider or a group. Nothing checks the aspect group or the buttons, so name those yourself. Buttons take their name from the text inside them.

Check what a screen reader hears

PartWhat a screen reader gets
AreaAlways role="application", and one tab stop once a photo is placed, described by Instructions. The role passes the arrow keys through a screen reader's browse mode. Pass your own role to change it
DialA slider that reads "4 degrees" in your locale. format changes the wording
ZoomA slider that reads "150%" in your locale. format changes the wording
AspectGroupA radiogroup with one tab stop. Each AspectItem is a radio. Outside a group, an item is a button with aria-pressed
Rotate, Orientation, ZoomStepButtons. Name a ZoomStep with aria-label. Orientation is disabled for a free, original or square crop
FlipA toggle button. aria-pressed follows mirrored
ResetA button that is hidden while nothing has changed. If it has focus when it hides, focus moves to Area, or to the first control in Root that can take focus when there is no Area or it cannot take focus
Every control abovearia-disabled while Root is disabled. A press does nothing, and the control stays in the tab order
ImageAn image named by its alt. Without alt, nothing
Handle, Grid, Mask, DialTicks, DialValue, ZoomThumbHidden

Describe the crop

Area points to Instructions with aria-describedby, so a screen reader reads its text when the crop gets focus:

<Cropper.Instructions>
  Arrows move the crop. Alt and arrows resize it.
</Cropper.Instructions>

Render one Instructions and one Announcer per Root, outside any toolbar you duplicate. A second Instructions gets the same id, and Area only reads the first. A second Announcer calls announce again and speaks each message twice.

Announce changes

A moving crop has no text, so a screen reader stays silent without Announcer. After each commit it calls announce and speaks what it returns, or nothing for null. This one speaks the position and width after each keyboard change. Dial and zoom keys commit as keyboard too, so it also speaks after an arrow press on either slider, on top of the slider's own value:

<Cropper.Announcer
  announce={({ center, size }, details) => {
    if (details.reason !== "keyboard") return null;
    const x = Math.round(center.x * 100);
    const y = Math.round(center.y * 100);
    const width = Math.round(size.width * 100);

    return `Crop at ${x}%, ${y}%, ${width}% wide`;
  }}
/>

details.reason says what made the change, such as keyboard, dial, pan or orientation, and details.previous holds the crop before it. A message equal to the last one is spoken again.

Move everything with keys

Every control works without a mouse. The keys live with each part: crop area, dial, zoom and aspect ratio. Buttons take Enter and Space like any button.

Escape also cancels a drag on the photo, a handle, Dial or Zoom, and the dialog around it stays open. A second Escape reaches the dialog.

A key pressed in a focusable child of Area, Dial or Zoom stays with that child, and Ctrl, Cmd and Alt shortcuts stay with the browser. The one exception is Alt + arrows on Area, which resizes the crop.

On keyboard focus, the stylesheet draws a two-tone ring inside Area in Canvas and CanvasText, so it shows on any photo and at padding 0. A mouse press adds nothing. Without the stylesheet, and on Dial and Zoom, the browser's default ring shows. Crop area shows how to recolour the ring.

Work without a drag

The dial, the handles and the photo pan need a drag, and keys do not replace that for someone who only has a pointer. WCAG 2.5.7 asks for a way with single taps. Zoom already jumps on a press, for the rest add plain buttons that write to the cropper. Pass a reason, so Announcer speaks these writes too. The effect frees the photo, see hooks:

"use client";

import { attachImage } from "@zriz/core/dom";
import { useCropper } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import { useEffect } from "react";

export const Editor = ({ src }: { src: string }) => {
  const cropper = useCropper();

  useEffect(() => () => attachImage(cropper, null), [cropper]);

  const turn = (by: number) => {
    cropper.setRotation(cropper.getValue().rotation + by, {
      reason: "keyboard",
    });
  };

  const move = (x: number, y: number) => {
    cropper.panBy(x, y, { reason: "keyboard" });
  };

  const resize = (by: number) => {
    const state = cropper.getCropState();

    if (state) {
      const crop = {
        ...state.crop,
        height: state.crop.height * by,
        width: state.crop.width * by,
      };

      cropper.setCropState(
        { ...state, crop },
        { keep: "center", reason: "keyboard" }
      );
    }
  };

  return (
    <>
      <Cropper.Root cropper={cropper} src={src}>
        <Cropper.Viewport
          style={{ maxWidth: 480, height: 360 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
      </Cropper.Root>
      <button onClick={() => turn(-1)}>Straighten left</button>
      <button onClick={() => turn(1)}>Straighten right</button>
      <button onClick={() => move(-0.01, 0)}>Move left</button>
      <button onClick={() => move(0.01, 0)}>Move right</button>
      <button onClick={() => move(0, -0.01)}>Move up</button>
      <button onClick={() => move(0, 0.01)}>Move down</button>
      <button onClick={() => resize(0.9)}>Smaller crop</button>
      <button onClick={() => resize(1.1)}>Larger crop</button>
    </>
  );
};

Respect reduced motion

With prefers-reduced-motion: reduce, motion that zriz starts on its own gets shorter. Drags, pinches and throws stay the same, so the photo follows your finger.

MotionFullReduced
RecentreSpring, about 340 msSpring, about 250 ms
Rotate 90°, reset or undo across a turnSpring, about 390 ms150 ms
FlipSpring, about 420 ms150 ms
Aspect, orientation, reset or undoSpring, about 460 ms150 ms

motion="reduce" on Root forces the short motion, and motion="always" keeps the full one. Root shows data-reduced-motion while it is on.

Spring-backs keep their length in both modes: the dial past its limit, the photo pulled past the crop and the zoom past its limits.

The stylesheet's fades keep their length in both modes: the mask dims over 200 ms and comes back over 300 ms, and the grid fades in over 250 ms and out over 300 ms. After a gesture, the thirds grid and data-interacting on Root and Mask stay until the crop starts to recentre, 300 ms by default. On Area, data-interacting clears when the gesture ends.

API

Announcer

Renders a visually hidden <div role="status" aria-live="polite"> anywhere inside Root. With a native modal <dialog>, keep it inside the dialog, because showModal makes everything outside inert and screen readers lose it.

Prop
announce (value, details) => string | null Required
What to say after each commit. Return null to stay quiet

Instructions

Renders a <div hidden> anywhere inside Root, which Area points to with aria-describedby. It has no props of its own. Its children are the text.