diff --git a/package.json b/package.json
index f105d748..26ce2d81 100644
--- a/package.json
+++ b/package.json
@@ -16,12 +16,15 @@
   },
   "devDependencies": {
     "@types/chai": "^4.3.5",
+    "@types/lodash.escaperegexp": "^4.1.9",
     "@types/mocha": "^10.0.1",
     "@types/node": "18.11.9",
     "@types/node-fetch": "^2.6.4",
     "@types/selenium-webdriver": "^4.1.15",
     "commander": "^11.1.0",
     "live-server": "^1.2.1",
+    "lodash": "^4.17.21",
+    "lodash.escaperegexp": "^4.1.2",
     "mocha": "^10.2.0",
     "mocha-webdriver": "0.3.1",
     "node-fetch": "^2",
diff --git a/test/fixtures/docs/Images.grist b/test/fixtures/docs/Images.grist
new file mode 100644
index 00000000..c8f8d984
Binary files /dev/null and b/test/fixtures/docs/Images.grist differ
diff --git a/test/fixtures/images/image1.jpg b/test/fixtures/images/image1.jpg
new file mode 100644
index 00000000..136bb557
Binary files /dev/null and b/test/fixtures/images/image1.jpg differ
diff --git a/test/fixtures/images/image2.jpg b/test/fixtures/images/image2.jpg
new file mode 100644
index 00000000..0e764a72
Binary files /dev/null and b/test/fixtures/images/image2.jpg differ
diff --git a/test/fixtures/images/image3.jpg b/test/fixtures/images/image3.jpg
new file mode 100644
index 00000000..4a90653d
Binary files /dev/null and b/test/fixtures/images/image3.jpg differ
diff --git a/test/getGrist.ts b/test/getGrist.ts
index cad3ccaa..18be7d9b 100644
--- a/test/getGrist.ts
+++ b/test/getGrist.ts
@@ -7,7 +7,7 @@ import fetch from 'node-fetch';
 import {GristWebDriverUtils} from 'test/gristWebDriverUtils';
 
 
-type UserAction = Array<string | number | object | boolean | null | undefined>;
+
 
 /**
  * Set up mocha hooks for starting and stopping Grist. Return
@@ -198,26 +198,12 @@ export class GristUtils extends GristWebDriverUtils {
     await this.waitForServer();
   }
 
-  public async sendActionsAndWaitForServer(actions: UserAction[], optTimeout: number = 2000) {
-    const result = await driver.executeAsyncScript(async (actions: any, done: Function) => {
-      try {
-        await (window as any).gristDocPageModel.gristDoc.get().docModel.docData.sendActions(actions);
-        done(null);
-      } catch (err) {
-        done(String(err?.message || err));
-      }
-    }, actions);
-    if (result) {
-      throw new Error(result as string);
-    }
-    await this.waitForServer(optTimeout);
-  }
+
 
   public async clickWidgetPane() {
     const elem = this.driver.find('.test-config-widget-select .test-select-open');
     if (await elem.isPresent()) {
       await elem.click();
-      // if not present, may just be already selected.
     }
   }
 
@@ -226,6 +212,20 @@ export class GristUtils extends GristWebDriverUtils {
     await this.waitForServer();
   }
 
+  public async removeWidget(name: string|RegExp) {
+    await this.selectSectionByTitle(name);
+    await this.sendCommand('deleteSection');
+    await this.waitForServer();
+  }
+
+  public async addCustomSection(name: string, type: string, dataSource: string|RegExp= /Table1/) {
+    await this.toggleSidePanel('right', 'open');
+    await this.addNewSection(/Custom/, dataSource);
+    await this.clickWidgetPane();
+    await this.selectCustomWidget(type);
+    await this.waitForServer();
+  }
+
   public async setCustomWidgetAccess(option: "none" | "read table" | "full") {
     const text = {
       "none": "No document access",
@@ -275,6 +275,10 @@ export class GristUtils extends GristWebDriverUtils {
     return this.inCustomWidget(() => this.driver.find(selector).getText());
   }
 
+  public async getCustomWidgetElementParameter(selector: string, parameter: string): Promise<string> {
+    return this.inCustomWidget(() => this.driver.find(selector).getAttribute(parameter));
+  }
+
   public async executeScriptInCustomWidget<T>(script: Function, ...args: any[]): Promise<T> {
     return this.inCustomWidget(() => {
       return driver.executeScript(script, ...args);
diff --git a/test/gristWebDriverUtils.ts b/test/gristWebDriverUtils.ts
index 77d92ef0..7b2d407a 100644
--- a/test/gristWebDriverUtils.ts
+++ b/test/gristWebDriverUtils.ts
@@ -8,15 +8,17 @@
  * easily.
  */
 
-import { WebDriver, WebElement } from 'mocha-webdriver';
+import { Key, WebDriver, WebElement, WebElementPromise } from 'mocha-webdriver';
+import escapeRegExp = require('lodash/escapeRegExp');
 
-type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom';
+type SectionTypes = 'Table' | 'Card' | 'Card List' | 'Chart' | 'Custom';
+type UserAction = Array<string | number | object | boolean | null | undefined>;
 
 export class GristWebDriverUtils {
   public constructor(public driver: WebDriver) {
   }
 
-  public isSidePanelOpen(which: 'right'|'left'): Promise<boolean> {
+  public isSidePanelOpen(which: 'right' | 'left'): Promise<boolean> {
     return this.driver.find(`.test-${which}-panel`).matches('[class*=-open]');
   }
 
@@ -31,14 +33,44 @@ export class GristWebDriverUtils {
   public async waitForServer(optTimeout: number = 2000) {
     await this.driver.wait(() => this.driver.executeScript(
       "return window.gristApp && (!window.gristApp.comm || !window.gristApp.comm.hasActiveRequests())"
-        + " && window.gristApp.testNumPendingApiRequests() === 0",
+      + " && window.gristApp.testNumPendingApiRequests() === 0",
       optTimeout,
       "Timed out waiting for server requests to complete"
     ));
   }
 
+  public async sendActionsAndWaitForServer(actions: UserAction[], optTimeout: number = 2000) {
+    const result = await this.driver.executeAsyncScript(async (actions: any, done: Function) => {
+      try {
+        await (window as any).gristDocPageModel.gristDoc.get().docModel.docData.sendActions(actions);
+        done(null);
+      } catch (err) {
+        done(String(err?.message || err));
+      }
+    }, actions);
+    if (result) {
+      throw new Error(result as string);
+    }
+    await this.waitForServer(optTimeout);
+  }
+
+  /**
+ * Runs a Grist command in the browser window.
+ */
+  public async sendCommand(name: string, argument: any = null) {
+    await this.driver.executeAsyncScript((name: any, argument: any, done: any) => {
+      const result = (window as any).gristApp.allCommands[name].run(argument);
+      if (result?.finally) {
+        result.finally(done);
+      } else {
+        done();
+      }
+    }, name, argument);
+    await this.waitForServer();
+  }
+
 
-  public async login(){
+  public async login() {
     //just click log in to get example account.
     const menu = await this.driver.findWait('.test-dm-account', 1000);
     await menu.click();
@@ -50,7 +82,7 @@ export class GristWebDriverUtils {
 
   public async waitForSidePanel() {
     // 0.4 is the duration of the transition setup in app/client/ui/PagePanels.ts for opening the
-  // side panes
+    // side panes
     const transitionDuration = 0.4;
 
     // let's add an extra delay of 0.1 for even more robustness
@@ -62,7 +94,7 @@ export class GristWebDriverUtils {
    * Toggles (opens or closes) the right or left panel and wait for the transition to complete. An optional
    * argument can specify the desired state.
    */
-  public async toggleSidePanel(which: 'right'|'left', goal: 'open'|'close'|'toggle' = 'toggle') {
+  public async toggleSidePanel(which: 'right' | 'left', goal: 'open' | 'close' | 'toggle' = 'toggle') {
     if ((goal === 'open' && await this.isSidePanelOpen(which)) ||
       (goal === 'close' && !await this.isSidePanelOpen(which))) {
       return;
@@ -80,14 +112,14 @@ export class GristWebDriverUtils {
    * Gets browser window dimensions.
    */
   public async getWindowDimensions(): Promise<WindowDimensions> {
-    const {width, height} = await this.driver.manage().window().getRect();
-    return {width, height};
+    const { width, height } = await this.driver.manage().window().getRect();
+    return { width, height };
   }
 
 
   // Add a new widget to the current page using the 'Add New' menu.
   public async addNewSection(
-    typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions
+    typeRe: RegExp | SectionTypes, tableRe: RegExp | string, options?: PageWidgetPickerOptions
   ) {
     // Click the 'Add widget to page' entry in the 'Add New' menu
     await this.driver.findWait('.test-dp-add-new', 2000).doClick();
@@ -100,8 +132,8 @@ export class GristWebDriverUtils {
   // Select type and table that matches respectively typeRe and tableRe and save. The widget picker
   // must be already opened when calling this function.
   public async selectWidget(
-    typeRe: RegExp|string,
-    tableRe: RegExp|string = '',
+    typeRe: RegExp | string,
+    tableRe: RegExp | string = '',
     options: PageWidgetPickerOptions = {}
   ) {
     const driver = this.driver;
@@ -229,9 +261,9 @@ export class GristWebDriverUtils {
     await this.openAccountMenu();
     await this.driver.find('.grist-floating-menu .test-dm-account-settings').click();
     //close alert if it is shown
-    if(await this.isAlertShown()){
+    if (await this.isAlertShown()) {
       await this.acceptAlert();
-    };
+    }
     await this.driver.findWait('.test-account-page-login-method', 5000);
     await this.waitForServer();
     return new ProfileSettingsPage(this);
@@ -300,12 +332,122 @@ export class GristWebDriverUtils {
     let oldDimensions: WindowDimensions;
     before(async () => {
       oldDimensions = await this.driver.manage().window().getRect();
-      await this.driver.manage().window().setRect({width: 1920, height: 1080});
+      await this.driver.manage().window().setRect({ width: 1920, height: 1080 });
     });
     after(async () => {
       await this.driver.manage().window().setRect(oldDimensions);
     });
   }
+
+  public async focusOnCell(columnName: string, row: number) {
+    const cell = await this.getCell({ col: columnName, rowNum: row });
+    await cell.click();
+  }
+  public async fillCell(columnName: string, row: number, value: string) {
+    await this.focusOnCell(columnName, row);
+    await this.driver.sendKeys(value)
+    await this.driver.sendKeys(Key.ENTER);
+  }
+
+  public async addColumn(table: string, name: string) {
+    // focus on table
+    await this.selectSectionByTitle(table);
+    // add new column using a shortcut
+    await this.driver.actions().keyDown(Key.ALT).sendKeys('=').keyUp(Key.ALT).perform();
+    // wait for rename panel to show up 
+    await this.driver.findWait('.test-column-title-popup', 1000);
+    // rename and accept
+    await this.driver.sendKeys(name);
+    await this.driver.sendKeys(Key.ENTER);
+    await this.waitForServer();
+  }
+
+  /**
+   * Click into a section without disrupting cursor positions.
+   */
+  public async selectSectionByTitle(title: string|RegExp) {
+    try {
+      if (typeof title === 'string') {
+        title = new RegExp("^" + escapeRegExp(title) + "$", 'i');
+      }
+      // .test-viewsection is a special 1px width element added for tests only.
+      await this.driver.findContent(`.test-viewsection-title`, title).find(".test-viewsection-blank").click();
+    } catch (e) {
+      // We might be in mobile view.
+      await this.driver.findContent(`.test-viewsection-title`, title).findClosest(".view_leaf").click();
+    }
+  }
+
+
+  /**
+   * Returns a visible GridView cell. Options may be given as arguments directly, or as an object.
+   * - col: column name, or 0-based column index
+   * - rowNum: 1-based row numbers, as visible in the row headers on the left of the grid.
+   * - section: optional name of the section to use; will use active section if omitted.
+   */
+  public getCell(col: number | string, rowNum: number, section?: string): WebElementPromise;
+  public getCell(options: ICellSelect): WebElementPromise;
+  public getCell(colOrOptions: number | string | ICellSelect, rowNum?: number, section?: string): WebElementPromise {
+    const mapper = async (el: WebElement) => el;
+    const options: IColSelect<WebElement> = (typeof colOrOptions === 'object' ?
+      { col: colOrOptions.col, rowNums: [colOrOptions.rowNum], section: colOrOptions.section, mapper } :
+      { col: colOrOptions, rowNums: [rowNum!], section, mapper });
+    return new WebElementPromise(this.driver, this.getVisibleGridCells(options).then((elems) => elems[0]));
+  }
+
+  /**
+   * Returns visible cells of the GridView from a single column and one or more rows. Options may be
+   * given as arguments directly, or as an object.
+   * - col: column name, or 0-based column index
+   * - rowNums: array of 1-based row numbers, as visible in the row headers on the left of the grid.
+   * - section: optional name of the section to use; will use active section if omitted.
+   *
+   * If given by an object, then an array of columns is also supported. In this case, the return
+   * value is still a single array, listing all values from the first row, then the second, etc.
+   *
+   * Returns cell text by default. Mapper may be `identity` to return the cell objects.
+   */
+  public async getVisibleGridCells(col: number | string, rows: number[], section?: string): Promise<string[]>;
+  public async getVisibleGridCells<T = string>(options: IColSelect<T> | IColsSelect<T>): Promise<T[]>;
+  public async getVisibleGridCells<T>(
+    colOrOptions: number | string | IColSelect<T> | IColsSelect<T>, _rowNums?: number[], _section?: string
+  ): Promise<T[]> {
+
+    if (typeof colOrOptions === 'object' && 'cols' in colOrOptions) {
+      const { rowNums, section, mapper } = colOrOptions;    // tslint:disable-line:no-shadowed-variable
+      const columns = await Promise.all(colOrOptions.cols.map((oneCol) =>
+        this.getVisibleGridCells({ col: oneCol, rowNums, section, mapper })));
+      // This zips column-wise data into a flat row-wise array of values.
+      return ([] as T[]).concat(...rowNums.map((r, i) => columns.map((c) => c[i])));
+    }
+
+    const { col, rowNums, section, mapper = el => el.getText() }: IColSelect<any> = (
+      typeof colOrOptions === 'object' ? colOrOptions :
+        { col: colOrOptions, rowNums: _rowNums!, section: _section }
+    );
+
+    if (rowNums.includes(0)) {
+      // Row-numbers should be what the users sees: 0 is a mistake, so fail with a helpful message.
+      throw new Error('rowNum must not be 0');
+    }
+
+    const sectionElem = section ? await this.getSection(section) : await this.driver.findWait('.active_section', 4000);
+    const colIndex = (typeof col === 'number' ? col :
+      await sectionElem.findContent('.column_name', exactMatch(col)).index());
+
+    const visibleRowNums: number[] = await sectionElem.findAll('.gridview_data_row_num',
+      async (el) => parseInt(await el.getText(), 10));
+
+    const selector = `.gridview_data_scroll .record:not(.column_names) .field:nth-child(${colIndex + 1})`;
+    const fields = mapper ? await sectionElem.findAll(selector, mapper) : await sectionElem.findAll(selector);
+    return rowNums.map((n) => fields[visibleRowNums.indexOf(n)]);
+  }
+
+  public getSection(sectionOrTitle: string | WebElement): WebElement | WebElementPromise {
+    if (typeof sectionOrTitle !== 'string') { return sectionOrTitle; }
+    return this.driver.findContent(`.test-viewsection-title`, new RegExp("^" + escapeRegExp(sectionOrTitle) + "$", 'i'))
+      .findClosest('.viewsection_content');
+  }
 }
 
 class ProfileSettingsPage {
@@ -318,7 +460,7 @@ class ProfileSettingsPage {
   }
 
   public async setLanguage(language: string) {
-    await this.driver.findWait('.test-account-page-language .test-select-open',100).click();
+    await this.driver.findWait('.test-account-page-language .test-select-open', 100).click();
     await this.driver.findContentWait('.test-select-menu li', language, 100).click();
     await this.gu.waitForServer();
   }
@@ -332,11 +474,35 @@ export interface WindowDimensions {
 export interface PageWidgetPickerOptions {
   tableName?: string;
   /** Optional pattern of SELECT BY option to pick. */
-  selectBy?: RegExp|string;
+  selectBy?: RegExp | string;
   /** Optional list of patterns to match Group By columns. */
-  summarize?: (RegExp|string)[];
+  summarize?: (RegExp | string)[];
   /** If true, configure the widget selection without actually adding to the page. */
   dontAdd?: boolean;
   /** If true, dismiss any tooltips that are shown. */
   dismissTips?: boolean;
-}
\ No newline at end of file
+}
+
+export interface IColsSelect<T = WebElement> {
+  cols: Array<number | string>;
+  rowNums: number[];
+  section?: string | WebElement;
+  mapper?: (e: WebElement) => Promise<T>;
+}
+
+export interface IColSelect<T = WebElement> {
+  col: number | string;
+  rowNums: number[];
+  section?: string | WebElement;
+  mapper?: (e: WebElement) => Promise<T>;
+}
+
+export interface ICellSelect {
+  col: number | string;
+  rowNum: number;
+  section?: string | WebElement;
+}
+
+export function exactMatch(value: string, flags?: string): RegExp {
+  return new RegExp(`^${escapeRegExp(value)}$`, flags);
+}
diff --git a/test/viewer.ts b/test/viewer.ts
new file mode 100644
index 00000000..2fc19ca7
--- /dev/null
+++ b/test/viewer.ts
@@ -0,0 +1,353 @@
+import { assert, driver } from 'mocha-webdriver';
+import { getGrist } from "./getGrist";
+
+const TEST_IMAGE = 'http://localhost:9998/test/fixtures/images/image1.jpg'
+const TEST_IMAGE2 = 'http://localhost:9998/test/fixtures/images/image2.jpg'
+const TEST_IMAGE3 = 'http://localhost:9998/test/fixtures/images/image3.jpg'
+
+
+describe('viewer', function () {
+  this.timeout(30000);
+  const grist = getGrist();
+  before(async function () {
+    const docId = await grist.upload('test/fixtures/docs/Images.grist');
+    await grist.openDoc(docId);
+    await grist.toggleSidePanel('right', 'open');
+    await grist.clickWidgetPane();
+  });
+
+  async function testIfErrorIsDisplayed() {
+    assert.equal(await grist.inCustomWidget(() => driver.find('#error').isPresent()), true);
+  }
+
+  describe('row transition', function () {
+    enum RowData {
+      NONE = 1,
+      NONE_NEXT = 2,
+      SINGLE_IMAGE = 3,
+      SINGLE_IMAGE_NEXT = 4,
+      MULTIPLE_IMAGES = 5,
+      MULTIPLE_IMAGES_NEXT = 6,
+
+    }
+    before(async function () {
+      //add set of images to the table
+      await grist.sendActionsAndWaitForServer(
+        [
+          ['AddRecord', 'Data', RowData.NONE, { Image: "" }],
+          ['AddRecord', 'Data', RowData.NONE_NEXT, { Image: "" }],
+          ['AddRecord', 'Data', RowData.SINGLE_IMAGE, { Image: TEST_IMAGE }],
+          ['AddRecord', 'Data', RowData.SINGLE_IMAGE_NEXT, { Image: TEST_IMAGE }],
+          ['AddRecord', 'Data', RowData.MULTIPLE_IMAGES, { Image: TEST_IMAGE + " " + TEST_IMAGE2 }],
+          ['AddRecord', 'Data', RowData.MULTIPLE_IMAGES_NEXT, { Image: TEST_IMAGE + " " + TEST_IMAGE2 }],
+        ]
+      )
+    });
+    it('none to none', async function () {
+      await selectDataRow(RowData.NONE);
+      await selectDataRow(RowData.NONE_NEXT);
+
+      await assertImageElementVisible(false);
+      await assertNavigationButtonsVisible(false);
+    });
+
+    it('none to single image', async function () {
+      await selectDataRow(RowData.NONE);
+      await selectDataRow(RowData.SINGLE_IMAGE);
+
+      await assertImageElementVisible(true);
+      await assertNavigationButtonsVisible(false);
+    });
+
+    it('none to multiple images', async function () {
+      await selectDataRow(RowData.NONE);
+      await selectDataRow(RowData.MULTIPLE_IMAGES);
+
+      await assertImageElementVisible(true);
+      await assertNavigationButtonsVisible(true);
+    });
+
+    it('single image to none', async function () {
+      await selectDataRow(RowData.SINGLE_IMAGE);
+      await selectDataRow(RowData.NONE);
+
+      await assertImageElementVisible(false);
+      await assertNavigationButtonsVisible(false);
+    });
+
+    it('single image to single image', async function () {
+      await selectDataRow(RowData.SINGLE_IMAGE);
+      await selectDataRow(RowData.SINGLE_IMAGE_NEXT);
+
+      await assertImageElementVisible(true);
+      await assertNavigationButtonsVisible(false);
+    });
+
+    it('single image to multiple images', async function () {
+      await selectDataRow(RowData.SINGLE_IMAGE);
+      await selectDataRow(RowData.MULTIPLE_IMAGES);
+
+      await assertImageElementVisible(true);
+      await assertNavigationButtonsVisible(true);
+    });
+
+    it('multiple images to none', async function () {
+      await selectDataRow(RowData.MULTIPLE_IMAGES);
+      await selectDataRow(RowData.NONE);
+
+      await assertImageElementVisible(false);
+      await assertNavigationButtonsVisible(false);
+    });
+
+    it('multiple images to single image', async function () {
+      await selectDataRow(RowData.MULTIPLE_IMAGES);
+      await selectDataRow(RowData.SINGLE_IMAGE);
+
+      await assertImageElementVisible(true);
+      await assertNavigationButtonsVisible(false);
+    });
+
+    it('multiple images to multiple images', async function () {
+      await selectDataRow(RowData.MULTIPLE_IMAGES);
+      await selectDataRow(RowData.MULTIPLE_IMAGES_NEXT);
+
+      await assertImageElementVisible(true);
+      await assertNavigationButtonsVisible(true);
+    });
+
+    after(async function () {
+      //remove row added in before block
+      await grist.sendActionsAndWaitForServer([
+        ['RemoveRecord', 'Data', 6],
+        ['RemoveRecord', 'Data', 5],
+        ['RemoveRecord', 'Data', 4],
+        ['RemoveRecord', 'Data', 3],
+        ['RemoveRecord', 'Data', 2],
+        ['RemoveRecord', 'Data', 1],
+      ], 1000);
+    });
+  });
+
+  describe('loading images', function () {
+    it('single column, should load image from that column without mapping', async function () {
+      await grist.fillCell('Image', 1, TEST_IMAGE);
+      await assertIfImageIsDisplayed(TEST_IMAGE);
+    });
+
+    //in the moment of writing this test, widget has default access only to unmapped columns that was in table at the moment of widget creation
+    // that means - if there were more that one column in the momnet of creation, error will be displayed. In other case, widget will still use first column
+    it('multiple columns - column added after widget, no mapping, should work based on first column', async function () {
+      //add new column
+      await grist.addColumn('DATA', 'description');
+      //because widget was displayed, no error should be shown, and data from first column should be displayed
+      await assertIfImageIsDisplayed(TEST_IMAGE)
+
+      await grist.undo(2);
+
+    });
+
+    it('multiple columns - column added before, no mapping, should show error', async function () {
+      // remove custom widget
+      await grist.removeWidget(/Image/);
+      //add new column
+      await grist.addColumn('DATA', 'description');
+      //add widget again
+      await grist.addCustomSection("Image", 'Image viewer', /Data/);
+      await grist.setCustomWidgetAccess('full');
+      //test if error is displayed
+      await testIfErrorIsDisplayed()
+      //undo create column 
+    });
+
+
+    it('multiple columns, with mapping, should load image from mapped column', async function () {
+      //select image widget
+      await grist.clickWidgetPane();
+      //add mappiing 
+      await grist.setCustomWidgetMapping('ImageUrl', /Image/);
+      // check if image is showed 
+      await assertIfImageIsDisplayed(TEST_IMAGE);
+    });
+
+    after(async function () {
+      //remove setted cell
+      await grist.sendActionsAndWaitForServer([
+        ['RemoveRecord', 'Data', 1],
+      ], 1000);
+    });
+  });
+  describe('navigation', function () {
+    before(async function () {
+      //remove all cells from image table 
+      await grist.sendActionsAndWaitForServer([['RemoveRecord', 'Data', 1]], 1000);
+    });
+    describe('no image', function () {
+      it('should have navigation buttons hidden', async function () {
+        await assertNavigationButtonsVisible(false);
+      });
+      it('should have no image', async function () {
+        await assertImageElementVisible(false);
+      });
+    })
+    describe('single image', function () {
+      before(async function () {
+        //go to data table 
+        await grist.selectSectionByTitle(/^DATA$/);
+        await grist.fillCell('Image', 1, TEST_IMAGE);
+      });
+      it('should have navigation buttons hidden', async function () {
+        await assertNavigationButtonsVisible(false);
+      });
+      it('should have image', async function () {
+        await assertImageElementVisible(true);
+      });
+      after(async function () {
+        //remove setted cell
+        await grist.undo();
+      });
+    })
+    describe('multiple images', function () {
+      const nextButtonId = '#calendar-button-next';
+      const previousButtonId = '#calendar-button-previous';
+      before(async function () {
+        await grist.selectSectionByTitle(/^DATA$/);
+        //input 3 images in the same cell, separated by space
+        await grist.fillCell('Image', 1, `${TEST_IMAGE} this is some garbage content ` +
+          `${TEST_IMAGE2} and event more garbage ` +
+          `${TEST_IMAGE3}`);
+      });
+
+      it('should have navigation buttons visible', async function () {
+        await assertNavigationButtonsVisible(true);
+      });
+
+      it('should have image', async function () {
+        await assertImageElementVisible(true);
+      });
+
+      it('should navigate to next image by button', async function () {
+        await grist.inCustomWidget(async () => await driver.find(nextButtonId).click());
+        await assertIfImageIsDisplayed(TEST_IMAGE2);
+        await grist.inCustomWidget(async () => await driver.find(nextButtonId).click());
+        await assertIfImageIsDisplayed(TEST_IMAGE3);
+      });
+      it('should navigate to previous image by button', async function () {
+        await grist.inCustomWidget(async () => await driver.find(previousButtonId).click());
+        await assertIfImageIsDisplayed(TEST_IMAGE2);
+        await grist.inCustomWidget(async () => await driver.find(previousButtonId).click());
+        await assertIfImageIsDisplayed(TEST_IMAGE);
+      });
+
+      it('should navigate to last image by click next button right on last image', async function () {
+        // verify if first image is displayed
+        await assertIfImageIsDisplayed(TEST_IMAGE);
+        // click previous button one time 
+        await grist.inCustomWidget(async () => await driver.find(previousButtonId).click());
+        // verify if first image is displayed
+        await assertIfImageIsDisplayed(TEST_IMAGE3);
+      });
+
+      it('should navigate to first image by click previous button right on first image', async function () {
+        // verify if last image is displayed
+        await assertIfImageIsDisplayed(TEST_IMAGE3);
+        // click next button one more time 
+        await grist.inCustomWidget(async () => await driver.find(nextButtonId).click());
+        // verify if first image is displayed
+        await assertIfImageIsDisplayed(TEST_IMAGE);
+      });
+
+      it('should navigate to next image by swipe left', async function () {
+        await swipeLeft('#viewer')
+        await assertIfImageIsDisplayed(TEST_IMAGE2);
+        await swipeLeft('#viewer')
+        await assertIfImageIsDisplayed(TEST_IMAGE3);
+      });
+
+      it('should navigate to previous image by swipe right', async function () {
+        await swipeRight('#viewer')
+        await assertIfImageIsDisplayed(TEST_IMAGE2);
+        await swipeRight('#viewer')
+        await assertIfImageIsDisplayed(TEST_IMAGE);
+      });
+
+      after(async function () {
+        //remove setted cell
+        await grist.undo();
+      });
+    })
+  });
+
+  async function swipeLeft(element: string | RegExp) {
+    grist.executeScriptInCustomWidget(swipeScript, element, 'left')
+  }
+
+  async function swipeRight(element: string | RegExp) {
+    grist.executeScriptInCustomWidget(swipeScript, element, 'right')
+  }
+
+  function swipeScript(elementTag: string, direction: 'left' | 'right') {
+    let mode = direction;
+    let element = document.querySelector(elementTag);
+
+    if (!element) {
+      console.error("Element was not found.");
+    }
+    else {
+
+      let touch = new Touch({
+        identifier: Date.now(),
+        target: element,
+        clientX: 0,
+      });
+
+      let touchStart = new TouchEvent('touchstart', {
+        touches: [touch],
+      });
+
+      element.dispatchEvent(touchStart);
+
+      let clientX = (mode === "left") ? -200 : 200;
+      touch = new Touch({
+        identifier: Date.now(),
+        target: element,
+        clientX: clientX,
+      });
+
+      let touchEnd = new TouchEvent('touchend', {
+        changedTouches: [touch]
+      });
+
+      element.dispatchEvent(touchEnd);
+    }
+  }
+
+  async function assertImageElementVisible(expected: boolean) {
+    const isVisible = await isImageElementVisible();
+    assert.equal(isVisible, expected, `Image was expected to be ${expected ? 'visible' : 'not visible'} but it was not.`);
+  }
+
+  async function assertNavigationButtonsVisible(expected: boolean) {
+    const isVisible = await areNavigationButtonsVisible();
+    assert.equal(isVisible, expected, `Navigation buttons were expected to be ${expected ? 'visible' : 'not visible'} but they were not.`);
+  }
+
+  async function assertIfImageIsDisplayed(url: string) {
+    await grist.waitToPass(async () => {
+      const img = await grist.getCustomWidgetElementParameter('img', 'src');
+      assert.equal(img, url);
+    });
+  }
+
+  async function areNavigationButtonsVisible() {
+    return await grist.inCustomWidget(async () => await driver.find('#navigation-buttons').isDisplayed());
+  }
+
+  async function isImageElementVisible() {
+    return await grist.inCustomWidget(async () => await driver.find('#viewer').isDisplayed());
+  }
+
+  async function selectDataRow(row: number) {
+    await grist.selectSectionByTitle(/^DATA$/);
+    await grist.focusOnCell('Image', row);
+  }
+});
diff --git a/viewer/index.html b/viewer/index.html
index 1c11cfc4..327d9a0d 100644
--- a/viewer/index.html
+++ b/viewer/index.html
@@ -1,84 +1,119 @@
 <!DOCTYPE html>
 <html lang="en">
-  <head>
-    <meta charset="utf-8">
-    <title>A cheap and cheerful image viewer</title>
-    <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
-    <style>
-      html, body, textarea {
-          width: 100%;
-          height: 100vh;
-          padding: 0;
-          margin: 0;
-      }
-      #error {
-          display: none;
-          background: red;
-          color: white;
-          padding: 20px;
-          text-align: center;
-      }
-      #viewer {
-          display: none;
-          text-align: center;
-      }
-      img {
-          width: 100%;
-          height: 100%;
-          object-fit: contain;
-      }
-    </style>
-  </head>
-  <body>
-    <div id="error"></div>
-    <img id="viewer" />
-    <script>
-      var viewer = document.getElementById('viewer');
-      function showError(msg) {
-        var el = document.getElementById('error')
-        if (!msg) {
-          el.style.display = 'none';
-        } else {
-          el.innerHTML = msg;
-          el.style.display = 'block';
-        }
-      }
-      grist.ready({
-        columns: [{ name: "ImageUrl", title: 'Image URL', type: 'Text'}],
-        requiredAccess: 'read table'
-      });
-      // Helper function that reads first value from a table with a single column.
-      function singleColumn(record) {
-        const columns = Object.keys(record || {}).filter(k => k !== 'id');
-        return columns.length === 1 ? record[columns[0]] : undefined;
-      }
-      grist.onNewRecord(() => {
-        showError("");
-        viewer.style.display = 'none';
-      });
-      grist.onRecord(function(record) {
-        // If user picked all columns, this helper function will return a mapped record.
-        const mapped = grist.mapColumnNames(record);
-        // We will fallback to reading a value from a single column to
-        // support old way of mapping (exposing only a single column).
-        // New widgets should only check if mapped object is truthy.
-        const data = mapped ? mapped.ImageUrl : singleColumn(record);
-        delete record.id;
-        var keys = Object.keys(record);
-        if (data === undefined) {
-          showError("Please choose a column to show in the Creator Panel.");
-        } else {
-          showError("");
-          if (!data) {
-            viewer.style.display = 'none';
-          } else {
-            var parts = data.split(' ');
-            var url = parts[parts.length - 1];
-            viewer.src = url;
-            viewer.style.display = 'block';
-          }
-        }
-      });
-    </script>
-  </body>
+
+<head>
+  <meta charset="utf-8">
+  <title>A cheap and cheerful image viewer</title>
+  <script src="https://docs.getgrist.com/grist-plugin-api.js"></script>
+  <style>
+    .icon-arrow-left {
+      mask-image: var(--icon-ArrowLeft);
+      -webkit-mask-image: var(--icon-ArrowLeft);
+    }
+
+    .icon-arrow-right {
+      mask-image: var(--icon-ArrowRight);
+      -webkit-mask-image: var(--icon-ArrowRight);
+    }
+
+
+    .icon {
+      position: relative;
+      display: inline-block;
+      vertical-align: middle;
+      mask-repeat: no-repeat;
+      -webkit-mask-repeat: no-repeat;
+      mask-position: center;
+      -webkit-mask-position: center;
+      mask-size: contain;
+      -webkit-mask-size: contain;
+      width: 100%;
+      height: 0;
+      padding-bottom: 100%;
+      background-color: var(--icon-color, var(--grist-theme-text, black));
+    }
+
+    :root {
+      --icon-ArrowLeft: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA4Ljc4NDkzNDYsMTQuNjcwNzUzIDIuNzg0NjI5Niw4LjYyMTMyMjUgYyAtMC4zNjgyMDg0LC0wLjM3MTIzIC0wLjM2ODIwODQsLTAuOTczMSAwLC0xLjM0NDMyIGwgNi4wMDAzMDUsLTYuMDQ5NDI0IGMgMC4zNjgyMSwtMC4zNzEyMjI3NiAwLjk2NTE5LC0wLjM3MTIyMjc2IDEuMzMzNDAwNCwwIDAuMzY4MjEsMC4zNzEyMjMgMC4zNjgyMSwwLjk3MzA5NCAwLDEuMzQ0MzE0IGwgLTQuMzkwNzUwNCw0LjQyNjY5IGggNy43ODA4OTA0IHYgMS45MDExNSBIIDUuNzI3NTg0NiBsIDQuMzkwNzUwNCw0LjQyNjcyMDUgYyAwLjM2ODIxLDAuMzcxMiAwLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAtMC4zNjgyMTA0LDAuMzcxMiAtMC45NjUxOTA0LDAuMzcxMiAtMS4zMzM0MDA0LDAgeiIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4=');
+      --icon-ArrowRight: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSA3LjIzMjAxMzcsMTQuNjcwNzUzIDEzLjIzMjMxOSw4LjYyMTMyMjUgYyAwLjM2ODIwOCwtMC4zNzEyMyAwLjM2ODIwOCwtMC45NzMxIDAsLTEuMzQ0MzIgTCA3LjIzMjAxMzcsMS4yMjc1Nzg1IGMgLTAuMzY4MjEsLTAuMzcxMjIyNzYgLTAuOTY1MTksLTAuMzcxMjIyNzYgLTEuMzMzNDAwNCwwIC0wLjM2ODIxLDAuMzcxMjIzIC0wLjM2ODIxLDAuOTczMDk0IDAsMS4zNDQzMTQgbCA0LjM5MDc1MDcsNC40MjY2OSBIIDIuNTA4NDczMyB2IDEuOTAxMTUgSCAxMC4yODkzNjQgTCA1Ljg5ODYxMzMsMTMuMzI2NDUzIGMgLTAuMzY4MjEsMC4zNzEyIC0wLjM2ODIxLDAuOTczMSAwLDEuMzQ0MyAwLjM2ODIxMDQsMC4zNzEyIDAuOTY1MTkwNCwwLjM3MTIgMS4zMzM0MDA0LDAgeiIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48L3N2Zz4=');
+      --icon-Tick: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLjYxODMwNjksNC42NzcwMjg0NyBDMTEuNzk2Njc4OSw0LjQ2NjIyNTE3IDEyLjExMjE2NzgsNC40Mzk5MzQ0MyAxMi4zMjI5NzExLDQuNjE4MzA2NDUgQzEyLjUzMzc3NDQsNC43OTY2Nzg0OCAxMi41NjAwNjUyLDUuMTEyMTY3NDEgMTIuMzgxNjkzMSw1LjMyMjk3MDcxIEw2LjUzMDY4ODI3LDEyLjIzNzc5NDYgTDMuNjQ2NDQ2NjEsOS4zNTM1NTI5OCBDMy40NTExODQ0Niw5LjE1ODI5MDg0IDMuNDUxMTg0NDYsOC44NDE3MDgzNSAzLjY0NjQ0NjYxLDguNjQ2NDQ2MiBDMy44NDE3MDg3Niw4LjQ1MTE4NDA2IDQuMTU4MjkxMjQsOC40NTExODQwNiA0LjM1MzU1MzM5LDguNjQ2NDQ2MiBMNi40NjkzMTE3MywxMC43NjIyMDQ1IEwxMS42MTgzMDY5LDQuNjc3MDI4NDcgWiIgZmlsbD0iIzAwMCIgZmlsbC1ydWxlPSJub256ZXJvIi8+PC9zdmc+');
+    }
+
+    html,
+    body,
+    textarea {
+      width: 100%;
+      height: 100vh;
+      padding: 0;
+      margin: 0;
+    }
+
+    #error {
+      display: none;
+      background: red;
+      color: white;
+      padding: 20px;
+      text-align: center;
+    }
+
+    #viewer {
+      display: none;
+      text-align: center;
+    }
+
+    img {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+    }
+
+    #navigation-buttons {
+      display: flex;
+      justify-content: space-between;
+      position: absolute;
+      top: 0;
+      width: 100%;
+      height: 100%;
+      pointer-events: none;
+    }
+
+    .btn {
+      pointer-events: auto;
+      cursor: pointer;
+      background-color: transparent;
+      border: none;
+      height: 100%;
+      width: 50px;
+      filter: drop-shadow(0 4px 4px var(--grist-theme-page-bg, black));
+    }
+
+    .btn:hover>.icon {
+      transform: scale(1.2);
+      /* Adjust this value to change the size of the icon on hover */
+    }
+
+    body {
+      display: flex;
+      flex-direction: column;
+      overflow: hidden;
+    }
+  </style>
+  <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
+</head>
+
+<body>
+  <div id="navigation-buttons" style="display: none">
+    <button id="calendar-button-previous" class="btn" onclick="imageRotator.previousImage()">
+      <span class="icon icon-arrow-left"></span>
+    </button>
+    <button id="calendar-button-next" class="btn" onclick="imageRotator.nextImage()">
+      <span class="icon icon-arrow-right"></span>
+    </button>
+  </div>
+  <div id="error"></div>
+  <img id="viewer" alt="image loaded by gris from table" src="" />
+  <script src="script.js"></script>
+</body>
+
 </html>
+ 
\ No newline at end of file
diff --git a/viewer/script.js b/viewer/script.js
new file mode 100644
index 00000000..300f9838
--- /dev/null
+++ b/viewer/script.js
@@ -0,0 +1,159 @@
+class ImageRotator {
+  _urls = [];
+  _urlIndex = 0;
+
+  _getElement() {
+    if (this.element === undefined || this.element === null || this.element === '') {
+      this.viewer = document.getElementById(this.elementCssTag);
+    }
+    return this.viewer;
+  }
+
+  previousImage() {
+    if (this._urlIndex > 0) {
+      this._urlIndex--;
+    } else {
+      this._urlIndex = this._urls.length - 1;
+    }
+    this.showImage();
+  }
+
+  nextImage() {
+    if (this._urlIndex < this._urls.length - 1) {
+      this._urlIndex++;
+    } else {
+      this._urlIndex = 0;
+    }
+    this.showImage();
+  }
+
+  setImages(urls) {
+    this._urls = urls;
+    this._urlIndex = 0;
+    this.showImage();
+  }
+
+  showImage() {
+    const url = this._urls[this._urlIndex];
+    const viewer = this._getElement();
+    if (!url) {
+      viewer.style.display = 'none';
+    } else {
+      viewer.src = url;
+      viewer.alt = `URL: ${url}`; // When url does not point to an image, the url itself is shown as alt text.
+      viewer.style.display = 'block';
+    }
+  }
+
+  constructor(element) {
+    this.elementCssTag = element;
+  }
+}
+
+class SwipeHandler {
+  _startX;
+  _endX;
+  //this sets the minimum swipe distance, to avoid noise and to filter actual swipes from just moving fingers
+  _treshold = 100;
+  onSwipeLeft;
+  onSwipeRight;
+
+  //Function to handle swipes
+  _handleTouch() {
+    //calculate the distance on x-axis and o y-axis. Check whether had the great moving ratio.
+    const xDist = this._endX - this._startX;
+    if (Math.abs(xDist) > this._treshold) {
+      if (xDist > 0) {
+        this.onSwipeRight();
+      } else {
+        this.onSwipeLeft();
+      }
+    }
+  }
+
+  constructor() {
+    const viewer = document.getElementById('viewer');
+    viewer.addEventListener('touchstart', (event) => {
+      this._startX = event.touches[0].clientX;
+    });
+    viewer.addEventListener('touchend', (event) => {
+      this._endX = event.changedTouches[0].clientX;
+      this._handleTouch();
+    });
+  }
+}
+
+let imageRotator;
+let swipeHandler;
+imageRotator = new ImageRotator('viewer');
+swipeHandler = new SwipeHandler();
+swipeHandler.onSwipeLeft = () => imageRotator.nextImage();
+swipeHandler.onSwipeRight = () => imageRotator.previousImage();
+
+window.onload = function () {
+  if ('ontouchstart' in window) {
+    var btns = document.getElementsByClassName('btn');
+    for (var i = 0; i < btns.length; i++) {
+      btns[i].style.display = 'none';
+    }
+  }
+};
+
+function showError(msg) {
+  let el = document.getElementById('error');
+  if (!msg) {
+    el.style.display = 'none';
+  } else {
+    el.innerHTML = msg;
+    el.style.display = 'block';
+  }
+}
+
+function toggleNavigationButtons(show = false) {
+  const buttons = document.getElementById('navigation-buttons');
+  if (show) {
+    buttons.style.display = 'flex';
+  } else {
+    buttons.style.display = 'none';
+  }
+}
+
+grist.ready({
+  columns: [{ name: "ImageUrl", title: 'Image URL', type: 'Text' }],
+  requiredAccess: 'read table',
+});
+
+// Helper function that reads first value from a table with a single column.
+function singleColumn(record) {
+  const columns = Object.keys(record || {}).filter(k => k !== 'id');
+  return columns.length === 1 ? record[columns[0]] : undefined;
+}
+
+grist.onNewRecord(() => {
+  showError("");
+  viewer.style.display = 'none';
+});
+grist.onRecord(function (record) {
+  // If user picked all columns, this helper function will return a mapped record.
+  const mapped = grist.mapColumnNames(record);
+  // We will fallback to reading a value from a single column to
+  // support old way of mapping (exposing only a single column).
+  // New widgets should only check if mapped object is truthy.
+  const data = mapped ? mapped.ImageUrl : singleColumn(record);
+  delete record.id;
+  let showNavigation = false;
+  if (data === undefined) {
+    showError("Please choose a column to show in the Creator Panel.");
+  } else {
+    showError("");
+    if (!data) {
+      viewer.style.display = 'none';
+    } else {
+      const imageUrlRegex = /(https?:\/\/[^\s]+)/g;
+      urls = data.match(imageUrlRegex) || [];
+      imageRotator.setImages(urls);
+      showNavigation = urls.length > 1;
+    }
+    toggleNavigationButtons(showNavigation);
+  }
+});
diff --git a/yarn.lock b/yarn.lock
index ed7d8ec9..3c9e4684 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -64,6 +64,18 @@
   resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.5.tgz#ae69bcbb1bebb68c4ac0b11e9d8ed04526b3562b"
   integrity sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==
 
+"@types/lodash.escaperegexp@^4.1.9":
+  version "4.1.9"
+  resolved "https://registry.yarnpkg.com/@types/lodash.escaperegexp/-/lodash.escaperegexp-4.1.9.tgz#279b1efc46b54b54ad31702b32b5512671e01a9a"
+  integrity sha512-CMQc8v16YzhnPZnMhYQuJUksI8BmL1jy/lHJkiXi4/t24Lx7Qvy7qxew1abrDJGa1T2i0NzLLaR27NK1/qp5Ow==
+  dependencies:
+    "@types/lodash" "*"
+
+"@types/lodash@*":
+  version "4.14.202"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8"
+  integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==
+
 "@types/mocha@^10.0.1":
   version "10.0.1"
   resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b"
@@ -1471,6 +1483,16 @@ locate-path@^6.0.0:
   dependencies:
     p-locate "^5.0.0"
 
+lodash.escaperegexp@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
+  integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==
+
+lodash@^4.17.21:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
 log-symbols@4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"