diff --git a/app/javascript/mastodon/features/ui/components/image_loader.jsx b/app/javascript/mastodon/features/ui/components/image_loader.jsx
deleted file mode 100644
index b1417deda..000000000
--- a/app/javascript/mastodon/features/ui/components/image_loader.jsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import classNames from 'classnames';
-
-import { LoadingBar } from 'react-redux-loading-bar';
-
-import ZoomableImage from './zoomable_image';
-
-export default class ImageLoader extends PureComponent {
-
-  static propTypes = {
-    alt: PropTypes.string,
-    lang: PropTypes.string,
-    src: PropTypes.string.isRequired,
-    previewSrc: PropTypes.string,
-    width: PropTypes.number,
-    height: PropTypes.number,
-    onClick: PropTypes.func,
-    zoomedIn: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    alt: '',
-    lang: '',
-    width: null,
-    height: null,
-  };
-
-  state = {
-    loading: true,
-    error: false,
-    width: null,
-  };
-
-  removers = [];
-  canvas = null;
-
-  get canvasContext() {
-    if (!this.canvas) {
-      return null;
-    }
-    this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
-    return this._canvasContext;
-  }
-
-  componentDidMount () {
-    this.loadImage(this.props);
-  }
-
-  UNSAFE_componentWillReceiveProps (nextProps) {
-    if (this.props.src !== nextProps.src) {
-      this.loadImage(nextProps);
-    }
-  }
-
-  componentWillUnmount () {
-    this.removeEventListeners();
-  }
-
-  loadImage (props) {
-    this.removeEventListeners();
-    this.setState({ loading: true, error: false });
-    Promise.all([
-      props.previewSrc && this.loadPreviewCanvas(props),
-      this.hasSize() && this.loadOriginalImage(props),
-    ].filter(Boolean))
-      .then(() => {
-        this.setState({ loading: false, error: false });
-        this.clearPreviewCanvas();
-      })
-      .catch(() => this.setState({ loading: false, error: true }));
-  }
-
-  loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
-    const image = new Image();
-    const removeEventListeners = () => {
-      image.removeEventListener('error', handleError);
-      image.removeEventListener('load', handleLoad);
-    };
-    const handleError = () => {
-      removeEventListeners();
-      reject();
-    };
-    const handleLoad = () => {
-      removeEventListeners();
-      this.canvasContext.drawImage(image, 0, 0, width, height);
-      resolve();
-    };
-    image.addEventListener('error', handleError);
-    image.addEventListener('load', handleLoad);
-    image.src = previewSrc;
-    this.removers.push(removeEventListeners);
-  });
-
-  clearPreviewCanvas () {
-    const { width, height } = this.canvas;
-    this.canvasContext.clearRect(0, 0, width, height);
-  }
-
-  loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
-    const image = new Image();
-    const removeEventListeners = () => {
-      image.removeEventListener('error', handleError);
-      image.removeEventListener('load', handleLoad);
-    };
-    const handleError = () => {
-      removeEventListeners();
-      reject();
-    };
-    const handleLoad = () => {
-      removeEventListeners();
-      resolve();
-    };
-    image.addEventListener('error', handleError);
-    image.addEventListener('load', handleLoad);
-    image.src = src;
-    this.removers.push(removeEventListeners);
-  });
-
-  removeEventListeners () {
-    this.removers.forEach(listeners => listeners());
-    this.removers = [];
-  }
-
-  hasSize () {
-    const { width, height } = this.props;
-    return typeof width === 'number' && typeof height === 'number';
-  }
-
-  setCanvasRef = c => {
-    this.canvas = c;
-    if (c) this.setState({ width: c.offsetWidth });
-  };
-
-  render () {
-    const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
-    const { loading } = this.state;
-
-    const className = classNames('image-loader', {
-      'image-loader--loading': loading,
-      'image-loader--amorphous': !this.hasSize(),
-    });
-
-    return (
-      
-        {loading ? (
-          <>
-            
-              
-            
-
-            
-          >
-        ) : (
-          
-        )}
-      
 
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.jsx b/app/javascript/mastodon/features/ui/components/image_modal.jsx
deleted file mode 100644
index f08ce1534..000000000
--- a/app/javascript/mastodon/features/ui/components/image_modal.jsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import classNames from 'classnames';
-
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import { IconButton } from 'mastodon/components/icon_button';
-
-import ImageLoader from './image_loader';
-
-const messages = defineMessages({
-  close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-class ImageModal extends PureComponent {
-
-  static propTypes = {
-    src: PropTypes.string.isRequired,
-    alt: PropTypes.string.isRequired,
-    onClose: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  state = {
-    navigationHidden: false,
-  };
-
-  toggleNavigation = () => {
-    this.setState(prevState => ({
-      navigationHidden: !prevState.navigationHidden,
-    }));
-  };
-
-  render () {
-    const { intl, src, alt, onClose } = this.props;
-    const { navigationHidden } = this.state;
-
-    const navigationClassName = classNames('media-modal__navigation', {
-      'media-modal__navigation--hidden': navigationHidden,
-    });
-
-    return (
-      
-    );
-  }
-
-}
-
-export default injectIntl(ImageModal);
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.tsx b/app/javascript/mastodon/features/ui/components/image_modal.tsx
new file mode 100644
index 000000000..fa94cfcc3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/image_modal.tsx
@@ -0,0 +1,61 @@
+import { useCallback, useState } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import { IconButton } from 'mastodon/components/icon_button';
+
+import { ZoomableImage } from './zoomable_image';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export const ImageModal: React.FC<{
+  src: string;
+  alt: string;
+  onClose: () => void;
+}> = ({ src, alt, onClose }) => {
+  const intl = useIntl();
+  const [navigationHidden, setNavigationHidden] = useState(false);
+
+  const toggleNavigation = useCallback(() => {
+    setNavigationHidden((prevState) => !prevState);
+  }, [setNavigationHidden]);
+
+  const navigationClassName = classNames('media-modal__navigation', {
+    'media-modal__navigation--hidden': navigationHidden,
+  });
+
+  return (
+    
+  );
+};
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
index d69ceba53..9312805b5 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -22,7 +22,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
 import Video from 'mastodon/features/video';
 import { disableSwiping } from 'mastodon/initial_state';
 
-import ImageLoader from './image_loader';
+import { ZoomableImage } from './zoomable_image';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -59,6 +59,12 @@ class MediaModal extends ImmutablePureComponent {
     }));
   };
 
+  handleZoomChange = (zoomedIn) => {
+    this.setState({
+      zoomedIn,
+    });
+  };
+
   handleSwipe = (index) => {
     this.setState({
       index: index % this.props.media.size,
@@ -165,23 +171,26 @@ class MediaModal extends ImmutablePureComponent {
     const leftNav  = media.size > 1 && ;
     const rightNav = media.size > 1 && ;
 
-    const content = media.map((image) => {
+    const content = media.map((image, idx) => {
       const width  = image.getIn(['meta', 'original', 'width']) || null;
       const height = image.getIn(['meta', 'original', 'height']) || null;
       const description = image.getIn(['translation', 'description']) || image.get('description');
 
       if (image.get('type') === 'image') {
         return (
-          
         );
       } else if (image.get('type') === 'video') {
@@ -262,7 +271,7 @@ class MediaModal extends ImmutablePureComponent {
             onChangeIndex={this.handleSwipe}
             onTransitionEnd={this.handleTransitionEnd}
             index={index}
-            disabled={disableSwiping}
+            disabled={disableSwiping || zoomedIn}
           >
             {content}
           
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index cb1e499db..74fe59f32 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -39,7 +39,7 @@ import {
   ConfirmFollowToListModal,
   ConfirmMissingAltTextModal,
 } from './confirmation_modals';
-import ImageModal from './image_modal';
+import { ImageModal } from './image_modal';
 import MediaModal from './media_modal';
 import { ModalPlaceholder } from './modal_placeholder';
 import VideoModal from './video_modal';
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
deleted file mode 100644
index c4129bf26..000000000
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
+++ /dev/null
@@ -1,402 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-const MIN_SCALE = 1;
-const MAX_SCALE = 4;
-const NAV_BAR_HEIGHT = 66;
-
-const getMidpoint = (p1, p2) => ({
-  x: (p1.clientX + p2.clientX) / 2,
-  y: (p1.clientY + p2.clientY) / 2,
-});
-
-const getDistance = (p1, p2) =>
-  Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
-
-const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
-
-// Normalizing mousewheel speed across browsers
-// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
-const normalizeWheel = event => {
-  // Reasonable defaults
-  const PIXEL_STEP = 10;
-  const LINE_HEIGHT = 40;
-  const PAGE_HEIGHT = 800;
-
-  let sX = 0,
-    sY = 0, // spinX, spinY
-    pX = 0,
-    pY = 0; // pixelX, pixelY
-
-  // Legacy
-  if ('detail' in event) {
-    sY = event.detail;
-  }
-  if ('wheelDelta' in event) {
-    sY = -event.wheelDelta / 120;
-  }
-  if ('wheelDeltaY' in event) {
-    sY = -event.wheelDeltaY / 120;
-  }
-  if ('wheelDeltaX' in event) {
-    sX = -event.wheelDeltaX / 120;
-  }
-
-  // side scrolling on FF with DOMMouseScroll
-  if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
-    sX = sY;
-    sY = 0;
-  }
-
-  pX = sX * PIXEL_STEP;
-  pY = sY * PIXEL_STEP;
-
-  if ('deltaY' in event) {
-    pY = event.deltaY;
-  }
-  if ('deltaX' in event) {
-    pX = event.deltaX;
-  }
-
-  if ((pX || pY) && event.deltaMode) {
-    if (event.deltaMode === 1) { // delta in LINE units
-      pX *= LINE_HEIGHT;
-      pY *= LINE_HEIGHT;
-    } else { // delta in PAGE units
-      pX *= PAGE_HEIGHT;
-      pY *= PAGE_HEIGHT;
-    }
-  }
-
-  // Fall-back if spin cannot be determined
-  if (pX && !sX) {
-    sX = (pX < 1) ? -1 : 1;
-  }
-  if (pY && !sY) {
-    sY = (pY < 1) ? -1 : 1;
-  }
-
-  return {
-    spinX: sX,
-    spinY: sY,
-    pixelX: pX,
-    pixelY: pY,
-  };
-};
-
-class ZoomableImage extends PureComponent {
-
-  static propTypes = {
-    alt: PropTypes.string,
-    lang: PropTypes.string,
-    src: PropTypes.string.isRequired,
-    width: PropTypes.number,
-    height: PropTypes.number,
-    onClick: PropTypes.func,
-    zoomedIn: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    alt: '',
-    lang: '',
-    width: null,
-    height: null,
-  };
-
-  state = {
-    scale: MIN_SCALE,
-    zoomMatrix: {
-      type: null, // 'width' 'height'
-      fullScreen: null, // bool
-      rate: null, // full screen scale rate
-      clientWidth: null,
-      clientHeight: null,
-      offsetWidth: null,
-      offsetHeight: null,
-      clientHeightFixed: null,
-      scrollTop: null,
-      scrollLeft: null,
-      translateX: null,
-      translateY: null,
-    },
-    dragPosition: { top: 0, left: 0, x: 0, y: 0 },
-    dragged: false,
-    lockScroll: { x: 0, y: 0 },
-    lockTranslate: { x: 0, y: 0 },
-  };
-
-  removers = [];
-  container = null;
-  image = null;
-  lastTouchEndTime = 0;
-  lastDistance = 0;
-
-  componentDidMount () {
-    let handler = this.handleTouchStart;
-    this.container.addEventListener('touchstart', handler);
-    this.removers.push(() => this.container.removeEventListener('touchstart', handler));
-    handler = this.handleTouchMove;
-    // on Chrome 56+, touch event listeners will default to passive
-    // https://www.chromestatus.com/features/5093566007214080
-    this.container.addEventListener('touchmove', handler, { passive: false });
-    this.removers.push(() => this.container.removeEventListener('touchend', handler));
-
-    handler = this.mouseDownHandler;
-    this.container.addEventListener('mousedown', handler);
-    this.removers.push(() => this.container.removeEventListener('mousedown', handler));
-
-    handler = this.mouseWheelHandler;
-    this.container.addEventListener('wheel', handler);
-    this.removers.push(() => this.container.removeEventListener('wheel', handler));
-    // Old Chrome
-    this.container.addEventListener('mousewheel', handler);
-    this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
-    // Old Firefox
-    this.container.addEventListener('DOMMouseScroll', handler);
-    this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
-
-    this._initZoomMatrix();
-  }
-
-  componentWillUnmount () {
-    this._removeEventListeners();
-  }
-
-  componentDidUpdate (prevProps) {
-    if (prevProps.zoomedIn !== this.props.zoomedIn) {
-      this._toggleZoom();
-    }
-  }
-
-  _removeEventListeners () {
-    this.removers.forEach(listeners => listeners());
-    this.removers = [];
-  }
-
-  mouseWheelHandler = e => {
-    e.preventDefault();
-
-    const event = normalizeWheel(e);
-
-    if (this.state.zoomMatrix.type === 'width') {
-      // full width, scroll vertical
-      this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
-    } else {
-      // full height, scroll horizontal
-      this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
-    }
-
-    // lock horizontal scroll
-    this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
-  };
-
-  mouseDownHandler = e => {
-    this.setState({ dragPosition: {
-      left: this.container.scrollLeft,
-      top: this.container.scrollTop,
-      // Get the current mouse position
-      x: e.clientX,
-      y: e.clientY,
-    } });
-
-    this.image.addEventListener('mousemove', this.mouseMoveHandler);
-    this.image.addEventListener('mouseup', this.mouseUpHandler);
-  };
-
-  mouseMoveHandler = e => {
-    const dx = e.clientX - this.state.dragPosition.x;
-    const dy = e.clientY - this.state.dragPosition.y;
-
-    this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
-    this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
-
-    this.setState({ dragged: true });
-  };
-
-  mouseUpHandler = () => {
-    this.image.removeEventListener('mousemove', this.mouseMoveHandler);
-    this.image.removeEventListener('mouseup', this.mouseUpHandler);
-  };
-
-  handleTouchStart = e => {
-    if (e.touches.length !== 2) return;
-
-    this.lastDistance = getDistance(...e.touches);
-  };
-
-  handleTouchMove = e => {
-    const { scrollTop, scrollHeight, clientHeight } = this.container;
-    if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
-      // prevent propagating event to MediaModal
-      e.stopPropagation();
-      return;
-    }
-    if (e.touches.length !== 2) return;
-
-    e.preventDefault();
-    e.stopPropagation();
-
-    const distance = getDistance(...e.touches);
-    const midpoint = getMidpoint(...e.touches);
-    const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
-    const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
-
-    this._zoom(scale, midpoint);
-
-    this.lastMidpoint = midpoint;
-    this.lastDistance = distance;
-  };
-
-  _zoom(nextScale, midpoint) {
-    const { scale, zoomMatrix } = this.state;
-    const { scrollLeft, scrollTop } = this.container;
-
-    // math memo:
-    // x = (scrollLeft + midpoint.x) / scrollWidth
-    // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
-    // scrollWidth = clientWidth * scale
-    // scrollWidth' = clientWidth * nextScale
-    // Solve x = x' for nextScrollLeft
-    const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
-    const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
-
-    this.setState({ scale: nextScale }, () => {
-      this.container.scrollLeft = nextScrollLeft;
-      this.container.scrollTop = nextScrollTop;
-      // reset the translateX/Y constantly
-      if (nextScale < zoomMatrix.rate) {
-        this.setState({
-          lockTranslate: {
-            x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
-            y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
-          },
-        });
-      }
-    });
-  }
-
-  handleClick = e => {
-    // don't propagate event to MediaModal
-    e.stopPropagation();
-    const dragged = this.state.dragged;
-    this.setState({ dragged: false });
-    if (dragged) return;
-    const handler = this.props.onClick;
-    if (handler) handler();
-  };
-
-  handleMouseDown = e => {
-    e.preventDefault();
-  };
-
-  _initZoomMatrix = () => {
-    const { width, height } = this.props;
-    const { clientWidth, clientHeight } = this.container;
-    const { offsetWidth, offsetHeight } = this.image;
-    const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
-
-    const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
-    const fullScreen = type === 'width' ?  width > clientWidth : height > clientHeightFixed;
-    const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
-    const scrollTop = type === 'width' ?  (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
-    const scrollLeft = (clientWidth - offsetWidth) / 2;
-    const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
-    const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
-
-    this.setState({
-      zoomMatrix: {
-        type: type,
-        fullScreen: fullScreen,
-        rate: rate,
-        clientWidth: clientWidth,
-        clientHeight: clientHeight,
-        offsetWidth: offsetWidth,
-        offsetHeight: offsetHeight,
-        clientHeightFixed: clientHeightFixed,
-        scrollTop: scrollTop,
-        scrollLeft: scrollLeft,
-        translateX: translateX,
-        translateY: translateY,
-      },
-    });
-  };
-
-  _toggleZoom () {
-    const { scale, zoomMatrix } = this.state;
-
-    if ( scale >= zoomMatrix.rate ) {
-      this.setState({
-        scale: MIN_SCALE,
-        lockScroll: {
-          x: 0,
-          y: 0,
-        },
-        lockTranslate: {
-          x: 0,
-          y: 0,
-        },
-      }, () => {
-        this.container.scrollLeft = 0;
-        this.container.scrollTop = 0;
-      });
-    } else {
-      this.setState({
-        scale: zoomMatrix.rate,
-        lockScroll: {
-          x: zoomMatrix.scrollLeft,
-          y: zoomMatrix.scrollTop,
-        },
-        lockTranslate: {
-          x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
-          y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
-        },
-      }, () => {
-        this.container.scrollLeft = zoomMatrix.scrollLeft;
-        this.container.scrollTop = zoomMatrix.scrollTop;
-      });
-    }
-  }
-
-  setContainerRef = c => {
-    this.container = c;
-  };
-
-  setImageRef = c => {
-    this.image = c;
-  };
-
-  render () {
-    const { alt, lang, src, width, height } = this.props;
-    const { scale, lockTranslate, dragged } = this.state;
-    const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
-    const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
-
-    return (
-      
-        

-      
 
-    );
-  }
-}
-
-export default ZoomableImage;
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.tsx b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
new file mode 100644
index 000000000..85e29e6ae
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
@@ -0,0 +1,319 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+
+import classNames from 'classnames';
+
+import { useSpring, animated, config } from '@react-spring/web';
+import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
+
+import { Blurhash } from 'mastodon/components/blurhash';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+
+const MIN_SCALE = 1;
+const MAX_SCALE = 4;
+const DOUBLE_CLICK_THRESHOLD = 250;
+
+interface ZoomMatrix {
+  containerWidth: number;
+  containerHeight: number;
+  imageWidth: number;
+  imageHeight: number;
+  initialScale: number;
+}
+
+const createZoomMatrix = (
+  container: HTMLElement,
+  image: HTMLImageElement,
+  fullWidth: number,
+  fullHeight: number,
+): ZoomMatrix => {
+  const { clientWidth, clientHeight } = container;
+  const { offsetWidth, offsetHeight } = image;
+
+  const type =
+    fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
+
+  const initialScale =
+    type === 'width'
+      ? Math.min(clientWidth, fullWidth) / offsetWidth
+      : Math.min(clientHeight, fullHeight) / offsetHeight;
+
+  return {
+    containerWidth: clientWidth,
+    containerHeight: clientHeight,
+    imageWidth: offsetWidth,
+    imageHeight: offsetHeight,
+    initialScale,
+  };
+};
+
+const useGesture = createUseGesture([dragAction, pinchAction]);
+
+const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => {
+  if (!zoomMatrix || scale === MIN_SCALE) {
+    return {
+      left: -Infinity,
+      right: Infinity,
+      top: -Infinity,
+      bottom: Infinity,
+    };
+  }
+
+  const { containerWidth, containerHeight, imageWidth, imageHeight } =
+    zoomMatrix;
+
+  const bounds = {
+    left: -Math.max(imageWidth * scale - containerWidth, 0) / 2,
+    right: Math.max(imageWidth * scale - containerWidth, 0) / 2,
+    top: -Math.max(imageHeight * scale - containerHeight, 0) / 2,
+    bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2,
+  };
+
+  return bounds;
+};
+
+interface ZoomableImageProps {
+  alt?: string;
+  lang?: string;
+  src: string;
+  width: number;
+  height: number;
+  onClick?: () => void;
+  onDoubleClick?: () => void;
+  onClose?: () => void;
+  onZoomChange?: (zoomedIn: boolean) => void;
+  zoomedIn?: boolean;
+  blurhash?: string;
+}
+
+export const ZoomableImage: React.FC = ({
+  alt = '',
+  lang = '',
+  src,
+  width,
+  height,
+  onClick,
+  onDoubleClick,
+  onClose,
+  onZoomChange,
+  zoomedIn,
+  blurhash,
+}) => {
+  useEffect(() => {
+    const handler = (e: Event) => {
+      e.preventDefault();
+    };
+
+    document.addEventListener('gesturestart', handler);
+    document.addEventListener('gesturechange', handler);
+    document.addEventListener('gestureend', handler);
+
+    return () => {
+      document.removeEventListener('gesturestart', handler);
+      document.removeEventListener('gesturechange', handler);
+      document.removeEventListener('gestureend', handler);
+    };
+  }, []);
+
+  const [dragging, setDragging] = useState(false);
+  const [loaded, setLoaded] = useState(false);
+  const [error, setError] = useState(false);
+
+  const containerRef = useRef(null);
+  const imageRef = useRef(null);
+  const doubleClickTimeoutRef = useRef | null>();
+  const zoomMatrixRef = useRef(null);
+
+  const [style, api] = useSpring(() => ({
+    x: 0,
+    y: 0,
+    scale: 1,
+    onRest: {
+      scale({ value }) {
+        if (!onZoomChange) {
+          return;
+        }
+        if (value === MIN_SCALE) {
+          onZoomChange(false);
+        } else {
+          onZoomChange(true);
+        }
+      },
+    },
+  }));
+
+  useGesture(
+    {
+      onDrag({
+        pinching,
+        cancel,
+        active,
+        last,
+        offset: [x, y],
+        velocity: [, vy],
+        direction: [, dy],
+        tap,
+      }) {
+        if (tap) {
+          if (!doubleClickTimeoutRef.current) {
+            doubleClickTimeoutRef.current = setTimeout(() => {
+              onClick?.();
+              doubleClickTimeoutRef.current = null;
+            }, DOUBLE_CLICK_THRESHOLD);
+          } else {
+            clearTimeout(doubleClickTimeoutRef.current);
+            doubleClickTimeoutRef.current = null;
+            onDoubleClick?.();
+          }
+
+          return;
+        }
+
+        if (!zoomedIn) {
+          // Swipe up/down to dismiss parent
+          if (last) {
+            if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
+              onClose?.();
+            }
+
+            void api.start({ y: 0, config: config.wobbly });
+            return;
+          } else if (dy !== 0) {
+            void api.start({ y, immediate: true });
+            return;
+          }
+
+          cancel();
+          return;
+        }
+
+        if (pinching) {
+          cancel();
+          return;
+        }
+
+        if (active) {
+          setDragging(true);
+        } else {
+          setDragging(false);
+        }
+
+        void api.start({ x, y });
+      },
+
+      onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
+        if (!imageRef.current) {
+          return;
+        }
+
+        if (first) {
+          const { width, height, x, y } =
+            imageRef.current.getBoundingClientRect();
+          const tx = ox - (x + width / 2);
+          const ty = oy - (y + height / 2);
+
+          memo = [style.x.get(), style.y.get(), tx, ty];
+        }
+
+        const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+        const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+
+        void api.start({ scale: s, x, y });
+
+        return memo as [number, number, number, number];
+      },
+    },
+    {
+      target: imageRef,
+      drag: {
+        from: () => [style.x.get(), style.y.get()],
+        filterTaps: true,
+        bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()),
+        rubberband: true,
+      },
+      pinch: {
+        scaleBounds: {
+          min: MIN_SCALE,
+          max: MAX_SCALE,
+        },
+        rubberband: true,
+      },
+    },
+  );
+
+  useEffect(() => {
+    if (!loaded || !containerRef.current || !imageRef.current) {
+      return;
+    }
+
+    zoomMatrixRef.current = createZoomMatrix(
+      containerRef.current,
+      imageRef.current,
+      width,
+      height,
+    );
+
+    if (!zoomedIn) {
+      void api.start({ scale: MIN_SCALE, x: 0, y: 0 });
+    } else if (style.scale.get() === MIN_SCALE) {
+      void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 });
+    }
+  }, [api, style.scale, zoomedIn, width, height, loaded]);
+
+  const handleClick = useCallback((e: React.MouseEvent) => {
+    // This handler exists to cancel the onClick handler on the media modal which would
+    // otherwise close the modal. It cannot be used for actual click handling because
+    // we don't know if the user is about to pan the image or not.
+
+    e.preventDefault();
+    e.stopPropagation();
+  }, []);
+
+  const handleLoad = useCallback(() => {
+    setLoaded(true);
+  }, [setLoaded]);
+
+  const handleError = useCallback(() => {
+    setError(true);
+  }, [setError]);
+
+  return (
+    
+      {!loaded && blurhash && (
+        
+          
+        
+      )}
+
+      
+
+      {!loaded && !error && 
}
+    
 
+  );
+};
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
index ec6e4d0a9..ed3fe0ee0 100644
--- a/app/javascript/styles/mastodon/basics.scss
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -63,6 +63,7 @@ body {
 
     &.with-modals--active {
       overflow-y: hidden;
+      overscroll-behavior: none;
     }
   }
 
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 097a2fa20..800cb473f 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -2448,49 +2448,6 @@ a.account__display-name {
   }
 }
 
-.image-loader {
-  position: relative;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-  scrollbar-width: none; /* Firefox */
-  -ms-overflow-style: none; /* IE 10+ */
-
-  * {
-    scrollbar-width: none; /* Firefox */
-    -ms-overflow-style: none; /* IE 10+ */
-  }
-
-  &::-webkit-scrollbar,
-  *::-webkit-scrollbar {
-    width: 0;
-    height: 0;
-    background: transparent; /* Chrome/Safari/Webkit */
-  }
-
-  .image-loader__preview-canvas {
-    max-width: $media-modal-media-max-width;
-    max-height: $media-modal-media-max-height;
-    background: url('../images/void.png') repeat;
-    object-fit: contain;
-  }
-
-  .loading-bar__container {
-    position: relative;
-  }
-
-  .loading-bar {
-    position: absolute;
-  }
-
-  &.image-loader--amorphous .image-loader__preview-canvas {
-    display: none;
-  }
-}
-
 .zoomable-image {
   position: relative;
   width: 100%;
@@ -2498,13 +2455,61 @@ a.account__display-name {
   display: flex;
   align-items: center;
   justify-content: center;
+  scrollbar-width: none;
+  overflow: hidden;
+  user-select: none;
 
   img {
     max-width: $media-modal-media-max-width;
     max-height: $media-modal-media-max-height;
     width: auto;
     height: auto;
-    object-fit: contain;
+    outline: 1px solid var(--media-outline-color);
+    outline-offset: -1px;
+    border-radius: 8px;
+    touch-action: none;
+  }
+
+  &--zoomed-in {
+    z-index: 9999;
+    cursor: grab;
+
+    img {
+      outline: none;
+      border-radius: 0;
+    }
+  }
+
+  &--dragging {
+    cursor: grabbing;
+  }
+
+  &--error img {
+    visibility: hidden;
+  }
+
+  &__preview {
+    max-width: $media-modal-media-max-width;
+    max-height: $media-modal-media-max-height;
+    position: absolute;
+    z-index: 1;
+    outline: 1px solid var(--media-outline-color);
+    outline-offset: -1px;
+    border-radius: 8px;
+    overflow: hidden;
+
+    canvas {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      z-index: -1;
+    }
+  }
+
+  .loading-indicator {
+    z-index: 2;
+    mix-blend-mode: luminosity;
   }
 }
 
@@ -5576,6 +5581,7 @@ a.status-card {
   z-index: 9999;
   pointer-events: none;
   user-select: none;
+  overscroll-behavior: none;
 }
 
 .modal-root__modal {
@@ -5709,7 +5715,7 @@ a.status-card {
   .picture-in-picture__footer {
     border-radius: 0;
     background: transparent;
-    padding: 20px 0;
+    padding: 16px;
 
     .icon-button {
       color: $white;
diff --git a/config/webpack/rules/babel.js b/config/webpack/rules/babel.js
index f1b53c360..76e41f3df 100644
--- a/config/webpack/rules/babel.js
+++ b/config/webpack/rules/babel.js
@@ -4,7 +4,7 @@ const { env, settings } = require('../configuration');
 
 // Those modules contain modern ES code that need to be transpiled for Webpack to process it
 const nodeModulesToProcess = [
-  '@reduxjs', 'fuzzysort', 'toygrad'
+  '@reduxjs', 'fuzzysort', 'toygrad', '@react-spring'
 ];
 
 module.exports = {
diff --git a/package.json b/package.json
index 2ef2e9b0a..c6b76527c 100644
--- a/package.json
+++ b/package.json
@@ -51,8 +51,10 @@
     "@gamestdio/websocket": "^0.3.2",
     "@github/webauthn-json": "^2.1.1",
     "@rails/ujs": "7.1.501",
+    "@react-spring/web": "^9.7.5",
     "@reduxjs/toolkit": "^2.0.1",
     "@svgr/webpack": "^5.5.0",
+    "@use-gesture/react": "^10.3.1",
     "arrow-key-navigation": "^1.2.0",
     "async-mutex": "^0.5.0",
     "axios": "^1.4.0",
diff --git a/yarn.lock b/yarn.lock
index 50b6c5e72..11d9c907c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2748,6 +2748,7 @@ __metadata:
     "@gamestdio/websocket": "npm:^0.3.2"
     "@github/webauthn-json": "npm:^2.1.1"
     "@rails/ujs": "npm:7.1.501"
+    "@react-spring/web": "npm:^9.7.5"
     "@reduxjs/toolkit": "npm:^2.0.1"
     "@svgr/webpack": "npm:^5.5.0"
     "@testing-library/dom": "npm:^10.2.0"
@@ -2783,6 +2784,7 @@ __metadata:
     "@types/webpack-env": "npm:^1.18.4"
     "@typescript-eslint/eslint-plugin": "npm:^8.0.0"
     "@typescript-eslint/parser": "npm:^8.0.0"
+    "@use-gesture/react": "npm:^10.3.1"
     arrow-key-navigation: "npm:^1.2.0"
     async-mutex: "npm:^0.5.0"
     axios: "npm:^1.4.0"
@@ -3198,6 +3200,72 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@react-spring/animated@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/animated@npm:9.7.5"
+  dependencies:
+    "@react-spring/shared": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
+  languageName: node
+  linkType: hard
+
+"@react-spring/core@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/core@npm:9.7.5"
+  dependencies:
+    "@react-spring/animated": "npm:~9.7.5"
+    "@react-spring/shared": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
+  languageName: node
+  linkType: hard
+
+"@react-spring/rafz@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/rafz@npm:9.7.5"
+  checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
+  languageName: node
+  linkType: hard
+
+"@react-spring/shared@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/shared@npm:9.7.5"
+  dependencies:
+    "@react-spring/rafz": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
+  languageName: node
+  linkType: hard
+
+"@react-spring/types@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/types@npm:9.7.5"
+  checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
+  languageName: node
+  linkType: hard
+
+"@react-spring/web@npm:^9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/web@npm:9.7.5"
+  dependencies:
+    "@react-spring/animated": "npm:~9.7.5"
+    "@react-spring/core": "npm:~9.7.5"
+    "@react-spring/shared": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
+  languageName: node
+  linkType: hard
+
 "@reduxjs/toolkit@npm:^2.0.1":
   version: 2.6.1
   resolution: "@reduxjs/toolkit@npm:2.6.1"
@@ -4414,6 +4482,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@use-gesture/core@npm:10.3.1":
+  version: 10.3.1
+  resolution: "@use-gesture/core@npm:10.3.1"
+  checksum: 10c0/2e3b5c0f7fe26cdb47be3a9c2a58a6a9edafc5b2895b07d2898eda9ab5a2b29fb0098b15597baa0856907b593075cd44cc69bba4785c9cfb7b6fabaa3b52cd3e
+  languageName: node
+  linkType: hard
+
+"@use-gesture/react@npm:^10.3.1":
+  version: 10.3.1
+  resolution: "@use-gesture/react@npm:10.3.1"
+  dependencies:
+    "@use-gesture/core": "npm:10.3.1"
+  peerDependencies:
+    react: ">= 16.8.0"
+  checksum: 10c0/978da66e4e7c424866ad52eba8fdf0ce93a4c8fc44f8837c7043e68c6a6107cd67e817fffb27f7db2ae871ef2f6addb0c8ddf1586f24c67b7e6aef1646c668cf
+  languageName: node
+  linkType: hard
+
 "@webassemblyjs/ast@npm:1.9.0":
   version: 1.9.0
   resolution: "@webassemblyjs/ast@npm:1.9.0"