diff --git a/arkindex/documents/api/elements.py b/arkindex/documents/api/elements.py
index 6afaefe15ccc5dc46bc4028441bf0d6cd162da5d..b953253741c0a84d159795b954d965944ddad25c 100644
--- a/arkindex/documents/api/elements.py
+++ b/arkindex/documents/api/elements.py
@@ -59,6 +59,7 @@ from arkindex.documents.serializers.elements import (
     ElementTypeSerializer,
     MetaDataBulkSerializer,
     MetaDataUpdateSerializer,
+    SelectionMoveSerializer,
 )
 from arkindex.documents.serializers.light import CorpusAllowedMetaDataSerializer, ElementTypeLightSerializer
 from arkindex.documents.serializers.ml import ElementTranscriptionSerializer
@@ -1853,3 +1854,12 @@ class ElementMove(CreateAPIView):
         move_element(source=source, destination=destination, user_id=self.request.user.id)
 
         return Response(serializer.data, status=status.HTTP_200_OK)
+
+
+@extend_schema(operation_id='MoveSelection', tags=['elements'])
+class SelectionMove(SelectionMixin, CorpusACLMixin, CreateAPIView):
+    """
+    Move selected elements from a given corpus to a new destination folder
+    """
+    serializer_class = SelectionMoveSerializer
+    permission_classes = (IsVerified,)
diff --git a/arkindex/documents/serializers/elements.py b/arkindex/documents/serializers/elements.py
index 9b4a106f991ae54498e872af5b02a1890d097ed8..8b7000e33cb2da2cdfbe943c2983706b946e3be5 100644
--- a/arkindex/documents/serializers/elements.py
+++ b/arkindex/documents/serializers/elements.py
@@ -3,6 +3,7 @@ import uuid
 from collections import defaultdict
 from uuid import UUID
 
+from django.conf import settings
 from django.contrib.gis.geos import LinearRing
 from django.core.exceptions import ValidationError as DjangoValidationError
 from django.core.validators import URLValidator
@@ -34,7 +35,10 @@ from arkindex.documents.serializers.light import (
 from arkindex.documents.serializers.ml import ClassificationSerializer
 from arkindex.images.models import Image
 from arkindex.images.serializers import ZoneSerializer
+from arkindex.project.fields import Array
+from arkindex.project.mixins import SelectionMixin
 from arkindex.project.serializer_fields import LinearRingField
+from arkindex.project.triggers import move_selection
 from arkindex.users.models import Role
 from arkindex.users.utils import get_max_level
 
@@ -927,3 +931,52 @@ class ElementDestinationSerializer(serializers.Serializer):
             raise ValidationError({'destination': [
                 "'{}' is a child of element '{}'".format(destination.id, source.id)
             ]})
+
+
+class SelectionMoveSerializer(serializers.Serializer, SelectionMixin):
+    corpus_id = serializers.PrimaryKeyRelatedField(queryset=Corpus.objects.none())
+    destination = serializers.PrimaryKeyRelatedField(queryset=Element.objects.none())
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if not self.context.get('request'):
+            # Do not raise Error in order to create OpenAPI schema
+            return
+        corpora = Corpus.objects.writable(self.context['request'].user)
+        self.fields['corpus_id'].queryset = corpora
+        self.fields['destination'].queryset = Element.objects.filter(corpus__in=corpora).select_related('corpus')
+
+    def validate(self, data):
+        data = super().validate(data)
+        corpus = data.get('corpus_id')
+        destination = data.get('destination')
+
+        if not settings.ARKINDEX_FEATURES['selection']:
+            raise ValidationError('Selection is not available on this instance.')
+
+        if not self.context['request'].user.selected_elements.filter(corpus=corpus).exists():
+            raise ValidationError({'corpus_id': ['No elements from the specified corpus have been selected.']})
+
+        if destination.corpus.id != corpus.id:
+            raise ValidationError({'destination': ['An element cannot be moved to a destination from another corpus.']})
+        selected_elements = self.context['request'].user.selected_elements.filter(corpus=corpus.id)
+        if selected_elements.filter(id=destination.id).exists():
+            raise ValidationError({'destination': ['An element cannot be moved into itself.']})
+
+        # Assert destination is not a source's direct ancestor already
+        if ElementPath.objects.filter(element__corpus_id=corpus.id, element__selections__user_id=self.context['request'].user.id, path__last=destination.id).exists():
+            raise ValidationError({'destination': [
+                "'{}' is already a direct parent of one or more selected elements.".format(destination.id)
+            ]})
+        # Assert destination is not a source's descendant
+        if destination.paths.filter(path__overlap=Array(selected_elements.values_list('id', flat=True))).exists():
+            raise ValidationError({'destination': [
+                "'{}' is a child of one or more selected elements.".format(destination.id)
+            ]})
+
+        return data
+
+    def save(self):
+        corpus = self.validated_data['corpus_id']
+        destination = self.validated_data['destination']
+        move_selection(corpus_id=corpus.id, destination=destination, user_id=self.context['request'].user.id)
diff --git a/arkindex/documents/tasks.py b/arkindex/documents/tasks.py
index f1016c64bf8bf6a4e5505cddc734fcfa41787d98..0f51e50970a0c55b10b4c24309cf6042c1602840 100644
--- a/arkindex/documents/tasks.py
+++ b/arkindex/documents/tasks.py
@@ -1,5 +1,6 @@
 import logging
 from typing import Optional
+from uuid import UUID
 
 from django.conf import settings
 from django.db.models import Q
@@ -199,3 +200,16 @@ def move_element(source: Element, destination: Element) -> None:
     for parent in parents:
         parent.remove_child(source)
     source.add_parent(destination)
+
+
+@job('high', timeout=settings.RQ_TIMEOUTS['move_element'])
+def move_selection(corpus_id: UUID, destination: Element) -> None:
+    rq_job = get_current_job()
+    assert rq_job is not None, 'This task can only be run in a RQ job context.'
+    assert rq_job.user_id is not None, 'This task requires a user ID to be defined on the RQ job.'
+
+    queryset = Element.objects.filter(selections__user_id=rq_job.user_id, corpus_id=corpus_id)
+    total = queryset.count()
+    for i, item in enumerate(queryset):
+        rq_job.set_progress(i / total)
+        move_element(source=item, destination=destination)
diff --git a/arkindex/documents/tests/tasks/test_move_selection.py b/arkindex/documents/tests/tasks/test_move_selection.py
new file mode 100644
index 0000000000000000000000000000000000000000..46c56aaf39c9229387612ed6beffcb7d11aeae79
--- /dev/null
+++ b/arkindex/documents/tests/tasks/test_move_selection.py
@@ -0,0 +1,53 @@
+from unittest.mock import call, patch
+
+from arkindex.documents.tasks import move_selection
+from arkindex.project.tests import FixtureTestCase
+from arkindex.users.models import User
+
+
+class TestMoveSelection(FixtureTestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        super().setUpTestData()
+        cls.user2 = User.objects.create(email='user2@test.test', display_name='User 2', verified_email=True)
+        cls.destination = cls.corpus.elements.get(name='Volume 2')
+        cls.page_1 = cls.corpus.elements.get(name='Volume 1, page 1r')
+        cls.page_2 = cls.corpus.elements.get(name='Volume 1, page 2r')
+        cls.page_3 = cls.corpus.elements.get(name='Volume 1, page 1v')
+
+    def test_no_rq_job(self):
+        with self.assertRaises(AssertionError) as ctx:
+            move_selection(corpus_id=self.corpus.id, destination=self.destination)
+        self.assertEqual(str(ctx.exception), 'This task can only be run in a RQ job context.')
+
+    @patch('arkindex.documents.tasks.get_current_job')
+    def test_no_user_id(self, job_mock):
+        job_mock.return_value.user_id = None
+        with self.assertRaises(AssertionError) as ctx:
+            move_selection(corpus_id=self.corpus.id, destination=self.destination)
+        self.assertEqual(str(ctx.exception), 'This task requires a user ID to be defined on the RQ job.')
+
+    @patch('arkindex.documents.tasks.get_current_job')
+    @patch('arkindex.documents.tasks.move_element')
+    def test_move_selected_elements(self, move_element_mock, job_mock):
+        job_mock.return_value.user_id = self.user.id
+
+        self.user.selected_elements.set([self.page_1, self.page_2])
+        # Another user's selection should not be used
+        self.user2.selected_elements.set([self.page_3])
+
+        move_selection(corpus_id=self.corpus.id, destination=self.destination)
+
+        self.assertEqual(job_mock.call_count, 1)
+        self.assertEqual(job_mock().set_progress.call_count, 2)
+        self.assertListEqual(job_mock().set_progress.call_args_list, [
+            call(.0),
+            call(.5),
+        ])
+
+        self.assertEqual(move_element_mock.call_count, 2)
+        self.assertCountEqual(move_element_mock.call_args_list, [
+            call(source=self.page_1, destination=self.destination),
+            call(source=self.page_2, destination=self.destination)
+        ])
diff --git a/arkindex/documents/tests/test_move_selection.py b/arkindex/documents/tests/test_move_selection.py
new file mode 100644
index 0000000000000000000000000000000000000000..daa93cc90dbfdafc6ca0f3fb1072bfa35cef349b
--- /dev/null
+++ b/arkindex/documents/tests/test_move_selection.py
@@ -0,0 +1,147 @@
+from unittest.mock import call, patch
+
+from django.test import override_settings
+from django.urls import reverse
+from rest_framework import status
+
+from arkindex.documents.models import Corpus
+from arkindex.project.tests import FixtureAPITestCase
+from arkindex.users.models import Role
+
+
+class TestMoveSelection(FixtureAPITestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        super().setUpTestData()
+        cls.destination = cls.corpus.elements.get(name='Volume 2')
+        cls.page = cls.corpus.elements.get(name='Volume 1, page 1r')
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection_requires_login(self):
+        with self.assertNumQueries(0):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection_requires_verified(self):
+        self.user.verified_email = False
+        self.user.save()
+        self.client.force_login(self.user)
+        with self.assertNumQueries(2):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    @override_settings(ARKINDEX_FEATURES={'selection': False})
+    def test_move_selection_disabled(self):
+        self.client.force_login(self.user)
+        with self.assertNumQueries(6):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+            self.assertDictEqual(
+                response.json(), {'non_field_errors': ['Selection is not available on this instance.']}
+            )
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection_wrong_acl(self):
+        private_corpus = Corpus.objects.create(name='private', public=False)
+        private_element = private_corpus.elements.create(
+            type=private_corpus.types.create(slug='folder'),
+        )
+
+        self.client.force_login(self.user)
+        with self.assertNumQueries(6):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(private_corpus.id), 'destination': str(private_element.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(
+            response.json(),
+            {
+                'corpus_id': [f'Invalid pk "{private_corpus.id}" - object does not exist.'],
+                'destination': [f'Invalid pk "{private_element.id}" - object does not exist.']
+            }
+        )
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection_wrong_destination(self):
+        self.client.force_login(self.user)
+        self.user.selected_elements.add(self.page)
+        with self.assertNumQueries(6):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': '12341234-1234-1234-1234-123412341234'}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(
+            response.json(),
+            {'destination': ['Invalid pk "12341234-1234-1234-1234-123412341234" - object does not exist.']}
+        )
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection_same_destination(self):
+        self.client.force_login(self.user)
+        self.user.selected_elements.add(self.page)
+        with self.assertNumQueries(8):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.page.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(
+            response.json(),
+            {'destination': ['An element cannot be moved into itself.']}
+        )
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_element_different_corpus(self):
+        corpus2 = Corpus.objects.create(name='new')
+        corpus2.memberships.create(user=self.user, level=Role.Contributor.value)
+        destination = corpus2.elements.create(type=corpus2.types.create(slug='folder'))
+        self.client.force_login(self.user)
+        self.user.selected_elements.add(self.page)
+        with self.assertNumQueries(6):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(destination.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(
+            response.json(),
+            {'destination': ['An element cannot be moved to a destination from another corpus.']}
+        )
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection_destination_is_direct_parent(self):
+        destination = self.corpus.elements.get(name='Volume 1')
+        self.client.force_login(self.user)
+        self.user.selected_elements.add(self.page)
+        with self.assertNumQueries(9):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(destination.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(
+            response.json(),
+            {'destination': [f"'{destination.id}' is already a direct parent of one or more selected elements."]}
+        )
+
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection_destination_is_child(self):
+        target = self.corpus.elements.get(name='Volume 1')
+        destination_id = self.page.id
+        self.client.force_login(self.user)
+        self.user.selected_elements.add(target)
+        with self.assertNumQueries(10):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(destination_id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+        self.assertDictEqual(
+            response.json(),
+            {'destination': [f"'{destination_id}' is a child of one or more selected elements."]}
+        )
+
+    @patch('arkindex.project.triggers.documents_tasks.move_selection.delay')
+    @override_settings(ARKINDEX_FEATURES={'selection': True})
+    def test_move_selection(self, delay_mock):
+        self.client.force_login(self.user)
+        self.user.selected_elements.add(self.page)
+        another_page = self.corpus.elements.get(name='Volume 1, page 1v')
+        self.user.selected_elements.add(another_page)
+        with self.assertNumQueries(10):
+            response = self.client.post(reverse('api:move-selection'), {'corpus_id': str(self.corpus.id), 'destination': str(self.destination.id)}, format='json')
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        self.assertEqual(delay_mock.call_count, 1)
+        self.assertEqual(delay_mock.call_args, call(
+            corpus_id=self.corpus.id,
+            destination=self.destination,
+            user_id=self.user.id,
+            description=f"Moving selected elements from corpus {self.corpus.id} to element {self.destination.name}"
+        ))
diff --git a/arkindex/project/api_v1.py b/arkindex/project/api_v1.py
index 7a9b35e74f08661dfba27b3a3dd65a1e22788390..4d87bb571fa0fc628c1fe8e0a80466189c61d2d9 100644
--- a/arkindex/project/api_v1.py
+++ b/arkindex/project/api_v1.py
@@ -56,6 +56,7 @@ from arkindex.documents.api.elements import (
     ElementTypeUpdate,
     ManageSelection,
     MetadataEdit,
+    SelectionMove,
     WorkerResultsDestroy,
 )
 from arkindex.documents.api.entities import (
@@ -120,6 +121,7 @@ api = [
     path('elements/<uuid:pk>/parents/', ElementParents.as_view(), name='elements-parents'),
     path('elements/<uuid:pk>/children/', ElementChildren.as_view(), name='elements-children'),
     path('elements/selection/', ManageSelection.as_view(), name='elements-selection'),
+    path('elements/selection/move/', SelectionMove.as_view(), name='move-selection'),
     path('elements/create/', ElementsCreate.as_view(), name='elements-create'),
     path('elements/type/', ElementTypeCreate.as_view(), name='element-type-create'),
     path('elements/type/<uuid:pk>/', ElementTypeUpdate.as_view(), name='element-type'),
diff --git a/arkindex/project/fields.py b/arkindex/project/fields.py
index 29591f30cbdba56ccef13190f286a83e0f18f702..8df42630e8749caeec850a39ee4c99d0279656cd 100644
--- a/arkindex/project/fields.py
+++ b/arkindex/project/fields.py
@@ -53,6 +53,12 @@ class ArrayField(fields.ArrayField):
             return LastItemTransformFactory(self.base_field)
 
 
+class Array(Func):
+    function = 'array'
+    arity = 1
+    output_field = fields.ArrayField
+
+
 class StripSlashURLField(URLField):
     """
     A custom URLField that strips slashes from the end of all URLs.
diff --git a/arkindex/project/triggers.py b/arkindex/project/triggers.py
index 112aadca64f1a315b9e2cf6fdf99a61605e6ab1c..9884f748b84bdd4c798e5bec9e53a434da90e15b 100644
--- a/arkindex/project/triggers.py
+++ b/arkindex/project/triggers.py
@@ -96,6 +96,19 @@ def move_element(source: Element, destination: Element, user_id: Optional[int] =
     )
 
 
+def move_selection(corpus_id: UUID, destination: Element, user_id: int) -> None:
+    """
+    Move selected elements (and all of their children) to a destination Element.
+    Delete all those elements' previous parents.
+    """
+    documents_tasks.move_selection.delay(
+        corpus_id=corpus_id,
+        destination=destination,
+        user_id=user_id,
+        description=f"Moving selected elements from corpus {corpus_id} to element {destination.name}"
+    )
+
+
 def initialize_activity(process: DataImport):
     """
     Initialize activity on every process elements for worker versions that are part of its workflow