Skip to main content

JavaScript
Make an Accessible Focal Point Picker

Get sample images
Use these to test out the picker

Download

JavaScript

// AZUL CODING ---------------------------------------
// JavaScript - Make an Accessible Focal Point Picker
// https://youtu.be/u_hJcnw-QF4


/**
 * Retrieves the focal point at the center of the image.
 *
 * @returns The focal point at the center of the image.
 */
function getCenterFocalPoint() {
    return { x: 0.5, y: 0.5 };
}

class FocalPointPicker {
    /**
     * Constructs a new FocalPointPicker instance.
     * 
     * @param imageElement The image element to attach the focal point picker to.
     * @param onChange Optional callback function to be called when the focal point is changed.
     * @param defaultPoint Optional default focal point.
     */
    constructor(imageElement, onChange, defaultPoint) {
        this.image = imageElement;
        this.container = document.createElement("div");
        this.pointer = document.createElement("div");
        this.crosshairVertical = document.createElement("div");
        this.crosshairHorizontal = document.createElement("div");
        this.focalPoint = defaultPoint || getCenterFocalPoint();
        this.onChange = onChange || (() => {});
        this.isDragging = false;
        this.originalNextSibling = imageElement.nextSibling;
        this.stepSize = 0.01;

        // Bind event handlers
        this.boundHandleClick = this.handleClick.bind(this);
        this.boundHandleDragStart = this.handleDragStart.bind(this);
        this.boundHandleDragMove = this.handleDragMove.bind(this);
        this.boundHandleDragEnd = this.handleDragEnd.bind(this);
        this.boundHandleKeyDown = this.handleKeyDown.bind(this);

        this.setupStyles();
        this.setupEventListeners();
        this.updatePointerPosition();
    }

    setupStyles() {
        this.container.style.cssText = `
            position: relative;
            display: inline-block;
        `;

        this.pointer.style.cssText = `
            position: absolute;
            width: 20px;
            height: 20px;
            border-radius: 50%;
            background-color: rgba(255, 0, 0, 0.5);
            border: 2px solid red;
            transform: translate(-50%, -50%);
            cursor: move;
            z-index: 2;
        `;

        const crosshairStyle = `
            position: absolute;
            background-color: rgba(255, 255, 255, 0.5);
            z-index: 1;
        `;

        this.crosshairVertical.style.cssText = crosshairStyle + `
            width: 2px;
            height: 100%;
            left: calc(50% - 1px);
            top: 0;
        `;

        this.crosshairHorizontal.style.cssText = crosshairStyle + `
            width: 100%;
            height: 2px;
            top: calc(50% - 1px);
        `;

        this.image.parentNode?.insertBefore(this.container, this.image);
        this.container.appendChild(this.image);
        this.container.appendChild(this.crosshairVertical);
        this.container.appendChild(this.crosshairHorizontal);    
        this.container.appendChild(this.pointer);

        // Make the pointer focusable
        this.pointer.tabIndex = 0;
        this.pointer.setAttribute("role", "slider");
        this.pointer.setAttribute("aria-label", "Focal point");
        this.updateAriaValues();
    }

    setupEventListeners() {
        this.container.addEventListener("click", this.boundHandleClick);
        this.pointer.addEventListener("mousedown", this.boundHandleDragStart);
        document.addEventListener("mousemove", this.boundHandleDragMove);
        document.addEventListener("mouseup", this.boundHandleDragEnd);
        this.pointer.addEventListener("keydown", this.boundHandleKeyDown);
    }

    updatePointerPosition() {
        this.pointer.style.left = `${this.focalPoint.x * 100}%`;
        this.pointer.style.top = `${this.focalPoint.y * 100}%`;
    }

    handleClick(event) {
        const rect = this.image.getBoundingClientRect();
        this.setFocalPoint({
            x: (event.clientX - rect.left) / rect.width,
            y: (event.clientY - rect.top) / rect.height
        });
    }

    handleDragStart(event) {
        event.preventDefault();
        this.isDragging = true;
    }

    handleDragMove(event) {
        if (!this.isDragging) return;

        const rect = this.image.getBoundingClientRect();
        this.setFocalPoint({
            x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
            y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
        });
    }

    handleDragEnd() {
        this.isDragging = false;
    }

    handleKeyDown(event) {
        let newX = this.focalPoint.x;
        let newY = this.focalPoint.y;

        switch (event.key) {
            case "ArrowLeft":
                newX = Math.max(0, newX - this.stepSize);
                break;
            case "ArrowRight":
                newX = Math.min(1, newX + this.stepSize);
                break;
            case "ArrowUp":
                newY = Math.max(0, newY - this.stepSize);
                break;
            case "ArrowDown":
                newY = Math.min(1, newY + this.stepSize);
                break;
            default:
                return;
        }

        event.preventDefault(); // Prevent scrolling
        this.setFocalPoint({ x: newX, y: newY });
    }

    updateAriaValues() {
        const positionFormatted = `X: ${Math.round(this.focalPoint.x * 100)}%, Y: ${Math.round(this.focalPoint.y * 100)}%`;

        this.pointer.setAttribute("aria-valuenow", `${Math.round(this.focalPoint.x * 100)}`);
        this.pointer.setAttribute("aria-valuetext", positionFormatted);
        this.pointer.setAttribute("title", positionFormatted);
    }

    /**
     * Gets the current focal point.
     *
     * @returns The current focal point.
     */
    getFocalPoint() {
        return this.focalPoint;
    }

    /**
     * Sets the focal point and updates related elements.
     * 
     * @param newFocalPoint The new focal point to be set.
     */
    setFocalPoint(newFocalPoint) {
        this.focalPoint = newFocalPoint;
        this.updatePointerPosition();
        this.updateAriaValues();
        this.onChange(this.focalPoint);
    }

    /**
     * Disposes of the focal point picker by removing event listeners,
     * re-inserting the image back into the DOM, and resetting styles.
     */
    dispose() {
        // Remove event listeners
        this.container.removeEventListener("click", this.boundHandleClick);
        this.pointer.removeEventListener("mousedown", this.boundHandleDragStart);
        document.removeEventListener("mousemove", this.boundHandleDragMove);
        document.removeEventListener("mouseup", this.boundHandleDragEnd);
        this.pointer.removeEventListener("keydown", this.boundHandleKeyDown);

        // Remove the image from the container
        this.container.removeChild(this.image);

        // Insert the image back to its original position in the DOM
        if (this.originalNextSibling) {
            this.originalNextSibling.parentNode?.insertBefore(this.image, this.originalNextSibling);
        } else {
            // If there was no next sibling, append it to the parent
            this.container.parentNode?.appendChild(this.image);
        }

        // Remove the container and other elements
        this.container.parentNode?.removeChild(this.container);

        // Reset image styles that might have been changed
        this.image.style.position = "";
        this.image.style.left = "";
        this.image.style.top = "";

        // Nullify references
        this.container = null;
        this.pointer = null;
        this.crosshairVertical = null;
        this.crosshairHorizontal = null;
        this.onChange = null;
        this.originalNextSibling = null;

        // Reset other properties
        this.focalPoint = { x: 0, y: 0 };
        this.isDragging = false;
    }
}

Enjoying this tutorial?


TypeScript

// AZUL CODING ---------------------------------------
// JavaScript - Make an Accessible Focal Point Picker
// https://youtu.be/u_hJcnw-QF4


export interface FocalPoint {
    /**
     * The x-coordinate of the focal point.
     */
    x: number;

    /**
     * The y-coordinate of the focal point.
     */
    y: number;
}

/**
 * Callback function for when the focal point changes.
 *
 * @param focalPoint The new focal point.
 */
export type OnChangeCallback = (focalPoint: FocalPoint) => void;

/**
 * Retrieves the focal point at the center of the image.
 *
 * @returns The focal point at the center of the image.
 */
export function getCenterFocalPoint(): FocalPoint {
    return { x: 0.5, y: 0.5 };
}


export default class FocalPointPicker {
    private image: HTMLImageElement;
    private container: HTMLDivElement;
    private pointer: HTMLDivElement;
    private crosshairVertical: HTMLDivElement;
    private crosshairHorizontal: HTMLDivElement;

    private focalPoint: FocalPoint;
    private onChange: OnChangeCallback;
    private isDragging: boolean = false;
    private stepSize: number = 0.01;

    private boundHandleClick: (event: MouseEvent) => void;
    private boundHandleDragStart: (event: MouseEvent) => void;
    private boundHandleDragMove: (event: MouseEvent) => void;
    private boundHandleDragEnd: (event: MouseEvent) => void;
    private boundHandleKeyDown: (event: KeyboardEvent) => void;
    private originalNextSibling: Node | null;

    /**
     * Constructs a new FocalPointPicker instance.
     * 
     * @param imageElement The image element to attach the focal point picker to.
     * @param onChange Optional callback function to be called when the focal point is changed.
     * @param defaultPoint Optional default focal point.
     */
    constructor(imageElement: HTMLImageElement, onChange?: OnChangeCallback, defaultPoint?: FocalPoint) {
        this.image = imageElement;
        this.container = document.createElement("div");
        this.pointer = document.createElement("div");
        this.crosshairVertical = document.createElement("div");
        this.crosshairHorizontal = document.createElement("div");
        this.focalPoint = defaultPoint || getCenterFocalPoint();
        this.onChange = onChange || (() => {});
        this.originalNextSibling = imageElement.nextSibling;

        // Bind event handlers
        this.boundHandleClick = this.handleClick.bind(this);
        this.boundHandleDragStart = this.handleDragStart.bind(this);
        this.boundHandleDragMove = this.handleDragMove.bind(this);
        this.boundHandleDragEnd = this.handleDragEnd.bind(this);
        this.boundHandleKeyDown = this.handleKeyDown.bind(this);

        this.setupStyles();
        this.setupEventListeners();
        this.updatePointerPosition();
    }

    private setupStyles(): void {
        this.container.style.cssText = `
            position: relative;
            display: inline-block;
        `;

        this.pointer.style.cssText = `
            position: absolute;
            width: 20px;
            height: 20px;
            border-radius: 50%;
            background-color: rgba(255, 0, 0, 0.5);
            border: 2px solid red;
            transform: translate(-50%, -50%);
            cursor: move;
            z-index: 2;
        `;

        const crosshairStyle = `
            position: absolute;
            background-color: rgba(255, 255, 255, 0.5);
            z-index: 1;
        `;

        this.crosshairVertical.style.cssText = crosshairStyle + `
            width: 2px;
            height: 100%;
            left: calc(50% - 1px);
            top: 0;
        `;

        this.crosshairHorizontal.style.cssText = crosshairStyle + `
            width: 100%;
            height: 2px;
            top: calc(50% - 1px);
        `;

        this.image.parentNode?.insertBefore(this.container, this.image);
        this.container.appendChild(this.image);
        this.container.appendChild(this.crosshairVertical);
        this.container.appendChild(this.crosshairHorizontal);    
        this.container.appendChild(this.pointer);

        // Make the pointer focusable
        this.pointer.tabIndex = 0;
        this.pointer.setAttribute("role", "slider");
        this.pointer.setAttribute("aria-label", "Focal point");
        this.updateAriaValues();
    }

    private setupEventListeners(): void {
        this.container.addEventListener("click", this.boundHandleClick);
        this.pointer.addEventListener("mousedown", this.boundHandleDragStart);
        document.addEventListener("mousemove", this.boundHandleDragMove);
        document.addEventListener("mouseup", this.boundHandleDragEnd);
        this.pointer.addEventListener("keydown", this.boundHandleKeyDown);
    }

    private updatePointerPosition(): void {
        this.pointer.style.left = `${this.focalPoint.x * 100}%`;
        this.pointer.style.top = `${this.focalPoint.y * 100}%`;
    }
  
    private handleClick(event: MouseEvent): void {
        const rect = this.image.getBoundingClientRect();
        this.setFocalPoint({
            x: (event.clientX - rect.left) / rect.width,
            y: (event.clientY - rect.top) / rect.height
        });
    }

    private handleDragStart(event: MouseEvent): void {
        event.preventDefault();
        this.isDragging = true;
    }

    private handleDragMove(event: MouseEvent): void {
        if (!this.isDragging) return;

        const rect = this.image.getBoundingClientRect();
        this.setFocalPoint({
            x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
            y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))
        });
    }

    private handleDragEnd(): void {
        this.isDragging = false;
    }

    private handleKeyDown(event: KeyboardEvent): void {
        let newX = this.focalPoint.x;
        let newY = this.focalPoint.y;

        switch (event.key) {
            case "ArrowLeft":
                newX = Math.max(0, newX - this.stepSize);
                break;
            case "ArrowRight":
                newX = Math.min(1, newX + this.stepSize);
                break;
            case "ArrowUp":
                newY = Math.max(0, newY - this.stepSize);
                break;
            case "ArrowDown":
                newY = Math.min(1, newY + this.stepSize);
                break;
            default:
                return;
        }

        event.preventDefault(); // Prevent scrolling
        this.setFocalPoint({ x: newX, y: newY });
    }  

    private updateAriaValues(): void {
        const positionFormatted = `X: ${Math.round(this.focalPoint.x * 100)}%, Y: ${Math.round(this.focalPoint.y * 100)}%`;

        this.pointer.setAttribute("aria-valuenow", `${Math.round(this.focalPoint.x * 100)}`);
        this.pointer.setAttribute("aria-valuetext", positionFormatted);
        this.pointer.setAttribute("title", positionFormatted);
    }

    /**
     * Gets the current focal point.
     *
     * @returns The current focal point.
     */
    public getFocalPoint(): FocalPoint {
        return this.focalPoint;
    }

    /**
     * Sets the focal point and updates related elements.
     * 
     * @param newFocalPoint The new focal point to be set.
     */
    public setFocalPoint(newFocalPoint: FocalPoint): void {
        this.focalPoint = newFocalPoint;
        this.updatePointerPosition();
        this.updateAriaValues();
        this.onChange(this.focalPoint);
    }

    /**
     * Disposes of the focal point picker by removing event listeners,
     * re-inserting the image back into the DOM, and resetting styles.
     */
    public dispose(): void {
        // Remove event listeners
        this.container.removeEventListener("click", this.boundHandleClick);
        this.pointer.removeEventListener("mousedown", this.boundHandleDragStart);
        document.removeEventListener("mousemove", this.boundHandleDragMove);
        document.removeEventListener("mouseup", this.boundHandleDragEnd);
        this.pointer.removeEventListener("keydown", this.boundHandleKeyDown);

        // Remove the image from the container
        this.container.removeChild(this.image);

        // Insert the image back to its original position in the DOM
        if (this.originalNextSibling) {
            this.originalNextSibling.parentNode?.insertBefore(this.image, this.originalNextSibling);
        } else {
            // If there was no next sibling, append it to the parent
            this.container.parentNode?.appendChild(this.image);
        }

        // Remove the container and other elements
        this.container.parentNode?.removeChild(this.container);

        // Reset image styles that might have been changed
        this.image.style.position = "";
        this.image.style.left = "";
        this.image.style.top = "";

        // Nullify references
        this.container = null!;
        this.pointer = null!;
        this.crosshairVertical = null!;
        this.crosshairHorizontal = null!;
        this.onChange = null!;
        this.originalNextSibling = null;

        // Reset other properties
        this.focalPoint = { x: 0, y: 0 };
        this.isDragging = false;
    }
}

HTML

<!-- AZUL CODING --------------------------------------- -->
<!-- JavaScript - Make an Accessible Focal Point Picker -->
<!-- https://youtu.be/u_hJcnw-QF4 -->


<!DOCTYPE html>
<html>
    <head>
        <title>Azul Coding</title>
        <style>
            body {
                margin: 30px;
                background-color: #03506E;
            }
            * {
                font-family: "Inter", sans-serif;
                font-weight: 500;
                font-size: 18px;
            }
            button {
                background-color: white;
                border: none;
                border-radius: 10px;
                padding: 6px 12px;
                cursor: pointer;
            }
            button:active {
                transform: scale(0.9);
            }
            .comparison {
                display: flex;
                gap: 25px;
                margin: 25px 0;
            }
            .comparison > div {
                /* 
                Looking for some sample images?
                https://quetzal.johnjds.co.uk/?ref=azul
                */
                background-image: url("image.jpg");
                background-size: cover;
                background-position: center;
            }
            #portrait {
                height: 350px;
                width: 250px;
            }
            #square {
                height: 350px;
                width: 350px;
            }
        </style>
        <script src="focal.js"></script>
    </head>
    <body>
        <img id="focal-image" src="image.jpg" width="625" alt="">
        <div class="comparison">
            <div id="portrait"></div>
            <div id="square"></div>
        </div>
        <button id="reset-btn">Reset</button>

        <script>
            const focalImage = document.getElementById("focal-image");
            const portraitImage = document.getElementById("portrait");
            const squareImage = document.getElementById("square");
            const resetButton = document.getElementById("reset-btn");

            function onFocalPointChange(point) {
                const x = `${Math.round(point.x * 100)}%`;
                const y = `${Math.round(point.y * 100)}%`;

                for (const image of [portraitImage, squareImage])
                    image.style.backgroundPosition = `${x} ${y}`;
            }

            const focalPicker = new FocalPointPicker(
                focalImage, onFocalPointChange);

            resetButton.addEventListener("click", () => {
                focalPicker.setFocalPoint(getCenterFocalPoint());
            });

            // Use focalPicker.dispose() to undo the creation of the focal point picker.
            // You can also use focalPicker.getFocalPoint() to get the current position.
        </script>
    </body>
</html>