diff --git a/public/demo-image/config.json b/public/demo-image/config.json
index 0032705805..b7b70a7882 100644
--- a/public/demo-image/config.json
+++ b/public/demo-image/config.json
@@ -1,5 +1,5 @@
 {
-  "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.5/src/parser/StudyConfigSchema.json",
+  "$schema": "https://raw.githubusercontent.com/revisit-studies/study/time-to-next/src/parser/StudyConfigSchema.json",
   "studyMetadata": {
     "title": "Simple Images as Stimuli: Decision-Making with Uncertainty Visualizations",
     "version": "pilot",
@@ -54,7 +54,9 @@
             "No"
           ]
         }
-      ]
+      ],
+      "nextButtonEnableTime": 3000,
+      "nextButtonDisableTime": 15000
     },
     "dotplot-medium": {
       "meta": {
diff --git a/src/analysis/individualStudy/table/TableView.tsx b/src/analysis/individualStudy/table/TableView.tsx
index 062ee0c530..dbbfccf77f 100644
--- a/src/analysis/individualStudy/table/TableView.tsx
+++ b/src/analysis/individualStudy/table/TableView.tsx
@@ -20,17 +20,19 @@ function AnswerCell({ cellData }: { cellData: StoredAnswer }) {
   return Number.isFinite(cellData.endTime) && Number.isFinite(cellData.startTime) ? (
     <Table.Td>
       <Stack miw={100}>
-        {Object.entries(cellData.answer).map(([key, storedAnswer]) => (
-          <Box key={`cell-${key}`}>
-            <Text fw={700} span>
-              {' '}
-              {`${key}: `}
-            </Text>
-            <Text span>
-              {`${storedAnswer}`}
-            </Text>
-          </Box>
-        ))}
+        {cellData.timedOut
+          ? <Text>Timed out</Text>
+          : Object.entries(cellData.answer).map(([key, storedAnswer]) => (
+            <Box key={`cell-${key}`}>
+              <Text fw={700} span>
+                {' '}
+                {`${key}: `}
+              </Text>
+              <Text span>
+                {`${storedAnswer}`}
+              </Text>
+            </Box>
+          ))}
       </Stack>
     </Table.Td>
   ) : (
@@ -237,6 +239,7 @@ export function TableView({
           endTime: Math.max(...Object.values(record.answers).filter((a) => a.endTime !== -1 && a.endTime !== undefined).map((a) => a.endTime)),
           answer: {},
           windowEvents: Object.values(record.answers).flatMap((a) => a.windowEvents),
+          timedOut: false, // not used
         }}
         key={`cell-${record.participantId}-total-duration`}
       />
diff --git a/src/components/TimedOut.tsx b/src/components/TimedOut.tsx
new file mode 100644
index 0000000000..61dec58ba6
--- /dev/null
+++ b/src/components/TimedOut.tsx
@@ -0,0 +1,9 @@
+import { Text } from '@mantine/core';
+
+export function TimedOut() {
+  return (
+    <Text>
+      Thank you for participating. Unfortunately, you have not answered the questions within the given time limit. Because of this, you are no longer eligible to participate in the study. You may close this window now.
+    </Text>
+  );
+}
diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx
index 94efff813d..fe982c05e9 100644
--- a/src/components/response/ResponseBlock.tsx
+++ b/src/components/response/ResponseBlock.tsx
@@ -5,6 +5,7 @@ import {
 
 import React, { useEffect, useMemo, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
+import { IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
 import {
   IndividualComponent,
   ResponseBlockLocation,
@@ -20,6 +21,8 @@ import { useAnswerField } from './utils';
 import ResponseSwitcher from './ResponseSwitcher';
 import { StoredAnswer, TrrackedProvenance } from '../../store/types';
 import { useStorageEngine } from '../../storage/storageEngineHooks';
+import { useStudyConfig } from '../../store/hooks/useStudyConfig';
+import { useNextStep } from '../../store/hooks/useNextStep';
 
 type Props = {
   status?: StoredAnswer;
@@ -42,6 +45,7 @@ export default function ResponseBlock({
   const storedAnswer = status?.answer;
 
   const studyId = useStudyId();
+  const studyConfig = useStudyConfig();
 
   const navigate = useNavigate();
 
@@ -62,6 +66,26 @@ export default function ResponseBlock({
 
   const showNextBtn = location === (configInUse?.nextButtonLocation || 'belowStimulus');
 
+  const nextButtonDisableTime = configInUse?.nextButtonDisableTime;
+  const nextButtonEnableTime = configInUse?.nextButtonEnableTime || 0;
+  const [timer, setTimer] = useState<number | undefined>(undefined);
+  // Start a timer on first render, update timer every 100ms
+  useEffect(() => {
+    let time = 0;
+    const interval = setInterval(() => {
+      time += 100;
+      setTimer(time);
+    }, 500);
+    return () => {
+      clearInterval(interval);
+    };
+  }, []);
+  useEffect(() => {
+    if (timer && nextButtonDisableTime && timer >= nextButtonDisableTime && studyConfig.uiConfig.timeoutReject) {
+      navigate('./__timeout');
+    }
+  }, [nextButtonDisableTime, timer, navigate, studyConfig.uiConfig.timeoutReject]);
+
   useEffect(() => {
     const iframeResponse = responses.find((r) => r.type === 'iframe');
     if (iframeAnswers && iframeResponse) {
@@ -156,10 +180,28 @@ export default function ResponseBlock({
         }
       });
 
-      setEnableNextButton((allowFailedTraining && newAttemptsUsed >= trainingAttempts) || (Object.values(correctAnswers).every((isCorrect) => isCorrect) && newAttemptsUsed <= trainingAttempts));
+      setEnableNextButton(
+        (
+          allowFailedTraining && newAttemptsUsed >= trainingAttempts
+        ) || (
+          Object.values(correctAnswers).every((isCorrect) => isCorrect)
+          && newAttemptsUsed <= trainingAttempts
+        ),
+      );
     }
   };
 
+  const buttonTimerSatisfied = useMemo(
+    () => {
+      const nextButtonDisableSatisfied = nextButtonDisableTime && timer ? timer <= nextButtonDisableTime : true;
+      const nextButtonEnableSatisfied = timer ? timer >= nextButtonEnableTime : true;
+      return nextButtonDisableSatisfied && nextButtonEnableSatisfied;
+    },
+    [nextButtonDisableTime, nextButtonEnableTime, timer],
+  );
+
+  const { goToNextStep } = useNextStep();
+
   return (
     <div style={style}>
       {responses.map((response, index) => {
@@ -216,11 +258,38 @@ export default function ResponseBlock({
         )}
         {showNextBtn && (
           <NextButton
-            disabled={(hasCorrectAnswerFeedback && !enableNextButton) || !answerValidator.isValid()}
+            disabled={(hasCorrectAnswerFeedback && !enableNextButton) || !answerValidator.isValid() || !buttonTimerSatisfied}
             label={configInUse.nextButtonText || 'Next'}
           />
         )}
       </Group>
+      {showNextBtn && nextButtonEnableTime > 0 && timer && timer < nextButtonEnableTime && (
+      <Alert mt="md" title="Please wait" color="blue" icon={<IconInfoCircle />}>
+        The next button will be enabled in
+        {' '}
+        {Math.ceil((nextButtonEnableTime - timer) / 1000)}
+        {' '}
+        seconds.
+      </Alert>
+      )}
+      {showNextBtn && nextButtonDisableTime && timer && (nextButtonDisableTime - timer) < 10000 && (
+        (nextButtonDisableTime - timer) > 0
+          ? (
+            <Alert mt="md" title="Next button disables soon" color="yellow" icon={<IconAlertTriangle />}>
+              The next button disables in
+              {' '}
+              {Math.ceil((nextButtonDisableTime - timer) / 1000)}
+              {' '}
+              seconds.
+            </Alert>
+          ) : !studyConfig.uiConfig.timeoutReject && (
+            <Alert mt="md" title="Next button disabled" color="red" icon={<IconAlertTriangle />}>
+              The next button has timed out and is now disabled.
+              <Group justify="right" mt="sm">
+                <Button onClick={() => goToNextStep(false)} variant="link" color="red">Proceed</Button>
+              </Group>
+            </Alert>
+          ))}
     </div>
   );
 }
diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx
index 2d5d050898..2cd93707ef 100644
--- a/src/controllers/ComponentController.tsx
+++ b/src/controllers/ComponentController.tsx
@@ -17,6 +17,7 @@ import { useStoreActions, useStoreDispatch } from '../store/store';
 import { StudyEnd } from '../components/StudyEnd';
 import { TrainingFailed } from '../components/TrainingFailed';
 import ResourceNotFound from '../ResourceNotFound';
+import { TimedOut } from '../components/TimedOut';
 
 // current active stimuli presented to the user
 export default function ComponentController() {
@@ -56,6 +57,11 @@ export default function ComponentController() {
     return <TrainingFailed />;
   }
 
+  // Handle timed out participants
+  if (currentComponent === '__timedOut') {
+    return <TimedOut />;
+  }
+
   if (currentComponent === 'Notfound') {
     return <ResourceNotFound email={studyConfig.uiConfig.contactEmail} />;
   }
diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json
index 65b9c466b8..e7f0a1aa38 100644
--- a/src/parser/LibraryConfigSchema.json
+++ b/src/parser/LibraryConfigSchema.json
@@ -60,6 +60,14 @@
             "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
             "type": "object"
           },
+          "nextButtonDisableTime": {
+            "description": "A timeout (in ms) after which the next button will be disabled.",
+            "type": "number"
+          },
+          "nextButtonEnableTime": {
+            "description": "A timer (in ms) after which the next button will be enabled.",
+            "type": "number"
+          },
           "nextButtonLocation": {
             "$ref": "#/definitions/ResponseBlockLocation",
             "description": "The location of the next button."
@@ -486,6 +494,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -658,6 +674,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -949,6 +973,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -1103,6 +1135,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -1272,6 +1312,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -1581,6 +1629,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json
index 76020b5211..2d18ed03b1 100644
--- a/src/parser/StudyConfigSchema.json
+++ b/src/parser/StudyConfigSchema.json
@@ -60,6 +60,14 @@
             "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
             "type": "object"
           },
+          "nextButtonDisableTime": {
+            "description": "A timeout (in ms) after which the next button will be disabled.",
+            "type": "number"
+          },
+          "nextButtonEnableTime": {
+            "description": "A timer (in ms) after which the next button will be enabled.",
+            "type": "number"
+          },
           "nextButtonLocation": {
             "$ref": "#/definitions/ResponseBlockLocation",
             "description": "The location of the next button."
@@ -486,6 +494,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -658,6 +674,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -908,6 +932,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -1062,6 +1094,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -1231,6 +1271,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
@@ -1652,6 +1700,10 @@
           "description": "The message to display when the study ends.",
           "type": "string"
         },
+        "timeoutReject": {
+          "description": "Whether to redirect a timed out participant to a rejection page. This only works for components where the `nextButtonDisableTime` field is set.",
+          "type": "boolean"
+        },
         "urlParticipantIdParam": {
           "description": "If the participant ID is passed in the URL, this is the name of the querystring parameter that is used to capture the participant ID (e.g. PROLIFIC_ID). This will allow a user to continue a study on different devices and browsers.",
           "type": "string"
@@ -1705,6 +1757,14 @@
           "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.",
           "type": "object"
         },
+        "nextButtonDisableTime": {
+          "description": "A timeout (in ms) after which the next button will be disabled.",
+          "type": "number"
+        },
+        "nextButtonEnableTime": {
+          "description": "A timer (in ms) after which the next button will be enabled.",
+          "type": "number"
+        },
         "nextButtonLocation": {
           "$ref": "#/definitions/ResponseBlockLocation",
           "description": "The location of the next button."
diff --git a/src/parser/types.ts b/src/parser/types.ts
index 5ccc830980..ef77b81424 100644
--- a/src/parser/types.ts
+++ b/src/parser/types.ts
@@ -114,6 +114,8 @@ export interface UIConfig {
    * Whether to prepend questions with their index (+ 1). This should only be used when all questions are in the same location, e.g. all are in the side bar.
    */
   enumerateQuestions?: boolean;
+  /** Whether to redirect a timed out participant to a rejection page. This only works for components where the `nextButtonDisableTime` field is set. */
+  timeoutReject?: boolean;
 }
 
 /**
@@ -480,6 +482,10 @@ export interface BaseIndividualComponent {
   description?: string;
   /** The instruction of the component. This is used to identify and provide additional information for the component in the admin panel. */
   instruction?: string;
+  /** A timeout (in ms) after which the next button will be disabled. */
+  nextButtonDisableTime?: number;
+  /** A timer (in ms) after which the next button will be enabled. */
+  nextButtonEnableTime?: number;
 }
 
 /**
diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts
index 197d3405e6..c6e36cfb13 100644
--- a/src/store/hooks/useNextStep.ts
+++ b/src/store/hooks/useNextStep.ts
@@ -82,7 +82,7 @@ export function useNextStep() {
   const startTime = useMemo(() => Date.now(), []);
 
   const windowEvents = useWindowEvents();
-  const goToNextStep = useCallback(() => {
+  const goToNextStep = useCallback((collectData = true) => {
     if (typeof currentStep !== 'number') {
       return;
     }
@@ -101,14 +101,18 @@ export function useNextStep() {
     const currentWindowEvents = windowEvents && 'current' in windowEvents && windowEvents.current ? windowEvents.current.splice(0, windowEvents.current.length) : [];
 
     if (dataCollectionEnabled && storedAnswer.endTime === -1) { // === -1 means the answer has not been saved yet
+      const toSave = {
+        answer: collectData ? answer : {},
+        startTime,
+        endTime,
+        provenanceGraph,
+        windowEvents: currentWindowEvents,
+        timedOut: !collectData,
+      };
       storeDispatch(
         saveTrialAnswer({
           identifier,
-          answer,
-          startTime,
-          endTime,
-          provenanceGraph,
-          windowEvents: currentWindowEvents,
+          ...toSave,
         }),
       );
       // Update database
@@ -116,9 +120,7 @@ export function useNextStep() {
         storageEngine.saveAnswers(
           {
             ...answers,
-            [identifier]: {
-              answer, startTime, endTime, provenanceGraph, windowEvents: currentWindowEvents,
-            },
+            [identifier]: toSave,
           },
         );
       }
diff --git a/src/store/store.tsx b/src/store/store.tsx
index 12c02d44ce..bee31bb4f4 100644
--- a/src/store/store.tsx
+++ b/src/store/store.tsx
@@ -22,7 +22,7 @@ export async function studyStoreCreator(
     .map((id, idx) => [
       `${id}_${idx}`,
       {
-        answer: {}, startTime: 0, endTime: -1, provenanceGraph: undefined, windowEvents: [],
+        answer: {}, startTime: 0, endTime: -1, provenanceGraph: undefined, windowEvents: [], timedOut: false,
       },
     ]));
   const emptyValidation: TrialValidation = Object.assign(
@@ -107,7 +107,7 @@ export async function studyStoreCreator(
         }: PayloadAction<{ identifier: string } & StoredAnswer>,
       ) {
         const {
-          identifier, answer, startTime, endTime, provenanceGraph, windowEvents,
+          identifier, answer, startTime, endTime, provenanceGraph, windowEvents, timedOut,
         } = payload;
         state.answers[identifier] = {
           answer,
@@ -115,6 +115,7 @@ export async function studyStoreCreator(
           endTime,
           provenanceGraph,
           windowEvents,
+          timedOut,
         };
       },
     },
diff --git a/src/store/types.ts b/src/store/types.ts
index f6c46b124a..10e677f38c 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -102,6 +102,8 @@ export interface StoredAnswer {
 ```
    */
   windowEvents: EventType[];
+  /** A boolean value that indicates whether the participant timed out on this question. */
+  timedOut: boolean;
 }
 
 export interface StimulusParams<T> {