diff --git a/ReadMe.md b/ReadMe.md
index 2d96d69..159694c 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -6,6 +6,16 @@ A **Pagination Table** & **Scroll List** component suite for [CRUD operation][1]
 
 [![NPM](https://nodei.co/npm/mobx-restful-table.png?downloads=true&downloadRank=true&stars=true)][5]
 
+## Components
+
+1. [File Preview](https://ideapp.dev/MobX-RESTful-table/functions/FilePreview-1.html)
+2. [Form Field](https://ideapp.dev/MobX-RESTful-table/functions/FormField-1.html)
+3. [REST Form](https://ideapp.dev/MobX-RESTful-table/classes/RestForm.html)
+4. [Pager](https://ideapp.dev/MobX-RESTful-table/functions/Pager-1.html)
+5. [REST Table](https://ideapp.dev/MobX-RESTful-table/classes/RestTable.html)
+6. [Scroll Boundary](https://ideapp.dev/MobX-RESTful-table/functions/ScrollBoundary-1.html)
+7. [Scroll List](https://ideapp.dev/MobX-RESTful-table/classes/ScrollList.html)
+
 ## Installation
 
 ```shell
@@ -220,5 +230,5 @@ export default ScrollListPage;
 [10]: https://github.com/idea2app/Next-Bootstrap-TS/blob/main/pages/pagination.tsx
 [11]: https://next-bootstrap-ts.vercel.app/pagination/
 [12]: https://next-bootstrap-ts.vercel.app/scroll-list/
-[13]: https://github.com/idea2app/Next-Bootstrap-TS/blob/scroll-list-example/components/Git
-[14]: https://github.com/idea2app/Next-Bootstrap-TS/blob/scroll-list-example/pages/scroll-list.tsx
+[13]: https://github.com/idea2app/Next-Bootstrap-TS/blob/main/components/Git
+[14]: https://github.com/idea2app/Next-Bootstrap-TS/blob/main/pages/scroll-list.tsx
diff --git a/package.json b/package.json
index f68eab1..84ff62b 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,9 @@
 {
   "name": "mobx-restful-table",
-  "version": "0.5.0",
+  "version": "0.6.0",
   "license": "LGPL-3.0",
   "author": "shiy2008@gmail.com",
-  "description": "A super Table component for CRUD operation, which is based on MobX RESTful & React.",
+  "description": "A Pagination Table & Scroll List component suite for CRUD operation, which is based on MobX RESTful & React.",
   "keywords": [
     "table",
     "form",
diff --git a/source/FilePreview.tsx b/source/FilePreview.tsx
new file mode 100644
index 0000000..8b9a8cc
--- /dev/null
+++ b/source/FilePreview.tsx
@@ -0,0 +1,40 @@
+import { HTMLAttributes, InputHTMLAttributes, FC } from 'react';
+import { Image, ImageProps } from 'react-bootstrap';
+
+export type FilePreviewProps = ImageProps &
+  HTMLAttributes<HTMLAudioElement> &
+  HTMLAttributes<HTMLVideoElement> &
+  HTMLAttributes<HTMLAnchorElement> & {
+    type: InputHTMLAttributes<HTMLInputElement>['accept'];
+    path: string;
+  };
+
+export const FilePreview: FC<FilePreviewProps> = ({ type, path, ...props }) => {
+  const [kind] = type.split('/'),
+    [name, ...rest] =
+      new URL(path, 'http://localhost').pathname
+        .split('/')
+        .at(-1)
+        ?.split('.') || [];
+  const extension = rest.join('.');
+
+  return kind === 'image' ? (
+    <Image fluid src={path} {...props} />
+  ) : kind === 'audio' ? (
+    <audio controls src={path} {...props} />
+  ) : kind === 'video' ? (
+    <video
+      muted
+      src={path}
+      onMouseEnter={({ currentTarget }) => currentTarget.play()}
+      onMouseLeave={({ currentTarget }) => currentTarget.pause()}
+      {...props}
+    />
+  ) : (
+    <a className="d-inline-block px-3 py-2" download href={path} {...props}>
+      {extension ? <i className={`bi bi-filetype-${extension} fs-1`} /> : path}
+    </a>
+  );
+};
+
+FilePreview.displayName = 'FilePreview';
diff --git a/source/RestForm.tsx b/source/RestForm.tsx
new file mode 100644
index 0000000..f5eb974
--- /dev/null
+++ b/source/RestForm.tsx
@@ -0,0 +1,96 @@
+import { formToJSON } from 'web-utility';
+import { TranslationModel } from 'mobx-i18n';
+import { DataObject, IDType, ListModel } from 'mobx-restful';
+import { observer } from 'mobx-react';
+import {
+  FormEvent,
+  InputHTMLAttributes,
+  PureComponent,
+  ReactNode,
+} from 'react';
+import { Form, Button } from 'react-bootstrap';
+
+import { FormField } from './FormField';
+
+export interface Field<T extends DataObject>
+  extends Pick<InputHTMLAttributes<HTMLInputElement>, 'type' | 'accept'> {
+  key?: keyof T;
+  renderLabel?: ReactNode | ((data: keyof T) => ReactNode);
+  renderInput?: (data: T) => ReactNode;
+}
+
+export interface RestFormProps<T extends DataObject> {
+  id?: IDType;
+  fields: Field<T>[];
+  store: ListModel<T>;
+  translater: TranslationModel<string, 'submit' | 'cancel'>;
+}
+
+@observer
+export class RestForm<T extends DataObject> extends PureComponent<
+  RestFormProps<T>
+> {
+  componentDidMount() {
+    const { id, store } = this.props;
+
+    if (id) store.getOne(id);
+  }
+
+  handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
+    event.preventDefault();
+
+    const { id, store } = this.props;
+
+    await store.updateOne(formToJSON(event.currentTarget), id);
+
+    store.clearCurrent();
+  };
+
+  renderInput = ({ key, type, renderLabel }: Field<T>) => {
+    const { currentOne } = this.props.store;
+    const label =
+      typeof renderLabel === 'function'
+        ? renderLabel?.(key)
+        : renderLabel || key;
+
+    return (
+      key && (
+        <FormField
+          className="mb-3"
+          key={key.toString()}
+          label={label}
+          type={type}
+          name={key.toString()}
+          defaultValue={currentOne[key]}
+        />
+      )
+    );
+  };
+
+  render() {
+    const { fields, store, translater } = this.props;
+    const { downloading, uploading } = store,
+      { t } = translater;
+    const loading = downloading > 0 || uploading > 0;
+
+    return (
+      <Form onSubmit={this.handleSubmit} onReset={() => store.clearCurrent()}>
+        {fields.map(this.renderInput)}
+
+        <footer className="d-flex gap-3">
+          <Button className="flex-fill" type="submit" disabled={loading}>
+            {t('submit')}
+          </Button>
+          <Button
+            className="flex-fill"
+            type="reset"
+            variant="danger"
+            disabled={loading}
+          >
+            {t('cancel')}
+          </Button>
+        </footer>
+      </Form>
+    );
+  }
+}
diff --git a/source/RestTable.tsx b/source/RestTable.tsx
index 6fdd92f..becf724 100644
--- a/source/RestTable.tsx
+++ b/source/RestTable.tsx
@@ -1,16 +1,11 @@
-import { isEmpty, uniqueID, formToJSON } from 'web-utility';
+import { isEmpty } from 'web-utility';
 import classNames from 'classnames';
 import { debounce } from 'lodash';
-import { observable } from 'mobx';
+import { computed, observable } from 'mobx';
 import { TranslationModel } from 'mobx-i18n';
-import { DataObject, IDType, ListModel } from 'mobx-restful';
+import { DataObject, IDType } from 'mobx-restful';
 import { observer } from 'mobx-react';
-import {
-  InputHTMLAttributes,
-  FormEvent,
-  PureComponent,
-  ReactNode,
-} from 'react';
+import { PureComponent, ReactNode } from 'react';
 import {
   Table,
   TableProps,
@@ -21,31 +16,27 @@ import {
 } from 'react-bootstrap';
 
 import { Pager } from './Pager';
-import { FormField } from './FormField';
+import { FilePreview } from './FilePreview';
+import { Field, RestForm, RestFormProps } from './RestForm';
 
 export interface Column<T extends DataObject>
-  extends Pick<InputHTMLAttributes<HTMLInputElement>, 'type'> {
-  key?: keyof T;
-  renderHead?: ReactNode | ((data: keyof T) => ReactNode);
+  extends Omit<Field<T>, 'renderLabel'> {
+  renderHead?: Field<T>['renderLabel'];
   renderBody?: (data: T) => ReactNode;
   renderFoot?: ReactNode | ((data: keyof T) => ReactNode);
 }
 
-export interface RestTableProps<T extends DataObject> extends TableProps {
+export interface RestTableProps<T extends DataObject>
+  extends TableProps,
+    Omit<RestFormProps<T>, 'id' | 'fields' | 'translater'> {
   editable?: boolean;
   deletable?: boolean;
   columns: Column<T>[];
-  store: ListModel<T>;
-  translater: TranslationModel<
-    string,
-    | 'create'
-    | 'edit'
-    | 'delete'
-    | 'submit'
-    | 'cancel'
-    | 'total_x_rows'
-    | 'sure_to_delete_x'
-  >;
+  translater: RestFormProps<T>['translater'] &
+    TranslationModel<
+      string,
+      'create' | 'edit' | 'delete' | 'total_x_rows' | 'sure_to_delete_x'
+    >;
   onCheck?: (keys: IDType[]) => any;
 }
 
@@ -81,89 +72,125 @@ export class RestTable<T extends DataObject> extends PureComponent<
     onCheck?.(this.checkedKeys);
   };
 
-  get columns(): Column<T>[] {
+  @computed
+  get checkColumn(): Column<T> {
     const { checkedKeys, toggleCheckAll } = this,
-      { editable, deletable, columns, store, translater, onCheck } = this.props;
-    const { t } = translater,
-      { indexKey, currentPage } = store;
+      { indexKey, currentPage } = this.props.store;
+
+    return {
+      renderHead: () => (
+        <Form.Check
+          type="checkbox"
+          name={`all-${indexKey.toString()}`}
+          value={checkedKeys + ''}
+          checked={
+            !!currentPage[0] &&
+            currentPage.every(({ [indexKey]: ID }) => checkedKeys.includes(ID))
+          }
+          // https://github.com/facebook/react/issues/1798
+          ref={(input: HTMLInputElement | null) =>
+            input &&
+            (input.indeterminate =
+              !!checkedKeys.length && checkedKeys.length < currentPage.length)
+          }
+          onClick={toggleCheckAll}
+          onKeyUp={({ key }) => key === ' ' && toggleCheckAll()}
+        />
+      ),
+      renderBody: ({ [indexKey]: ID }) => (
+        <Form.Check
+          type="checkbox"
+          name={indexKey.toString()}
+          value={ID}
+          checked={checkedKeys.includes(ID)}
+          onClick={() => this.toggleCheck(ID)}
+          onKeyUp={({ key }) => key === ' ' && this.toggleCheck(ID)}
+        />
+      ),
+    };
+  }
+
+  @computed
+  get operateColumn(): Column<T> {
+    const { editable, deletable, store, translater } = this.props;
+    const { t } = translater;
+
+    return {
+      renderBody: data => (
+        <>
+          {editable && (
+            <Button
+              className="text-nowrap m-1"
+              variant="warning"
+              size="sm"
+              onClick={() => (store.currentOne = data)}
+            >
+              {t('edit')}
+            </Button>
+          )}
+          {deletable && (
+            <Button
+              className="text-nowrap m-1"
+              variant="danger"
+              size="sm"
+              onClick={() => this.deleteList([data.id])}
+            >
+              {t('delete')}
+            </Button>
+          )}
+        </>
+      ),
+    };
+  }
+
+  @computed
+  get columns(): Column<T>[] {
+    const { editable, deletable, columns, onCheck } = this.props;
 
     return [
-      onCheck &&
-        ({
-          renderHead: () => (
-            <Form.Check
-              type="checkbox"
-              name={`all-${indexKey.toString()}`}
-              value={checkedKeys + ''}
-              checked={
-                !!currentPage[0] &&
-                currentPage.every(({ [indexKey]: ID }) =>
-                  checkedKeys.includes(ID),
-                )
-              }
-              // https://github.com/facebook/react/issues/1798
-              ref={(input: HTMLInputElement | null) =>
-                input &&
-                (input.indeterminate =
-                  !!checkedKeys.length &&
-                  checkedKeys.length < currentPage.length)
-              }
-              onClick={toggleCheckAll}
-              onKeyUp={({ key }) => key === ' ' && toggleCheckAll()}
-            />
-          ),
-          renderBody: ({ [indexKey]: ID }) => (
-            <Form.Check
-              type="checkbox"
-              name={indexKey.toString()}
-              value={ID}
-              checked={checkedKeys.includes(ID)}
-              onClick={() => this.toggleCheck(ID)}
-              onKeyUp={({ key }) => key === ' ' && this.toggleCheck(ID)}
-            />
-          ),
-        } as Column<T>),
-
-      ...columns,
-
-      (editable || deletable) &&
-        ({
-          renderBody: data => (
-            <>
-              {editable && (
-                <Button
-                  className="text-nowrap m-1"
-                  variant="warning"
-                  size="sm"
-                  onClick={() => (store.currentOne = data)}
-                >
-                  {t('edit')}
-                </Button>
-              )}
-              {deletable && (
-                <Button
-                  className="text-nowrap m-1"
-                  variant="danger"
-                  size="sm"
-                  onClick={() => this.deleteList([data.id])}
-                >
-                  {t('delete')}
-                </Button>
-              )}
-            </>
-          ),
-        } as Column<T>),
+      onCheck && this.checkColumn,
+
+      ...columns.map(
+        ({ type, key, accept, renderBody, ...column }) =>
+          ({
+            ...column,
+            type,
+            key,
+            renderBody:
+              type === 'url'
+                ? ({ [key]: value }) =>
+                    value && (
+                      <a target="_blank" href={value}>
+                        {value}
+                      </a>
+                    )
+                : type === 'file'
+                ? ({ [key]: value }) => (
+                    <FilePreview type={accept} path={value} />
+                  )
+                : renderBody,
+          } as Column<T>),
+      ),
+
+      (editable || deletable) && this.operateColumn,
     ].filter(Boolean);
   }
 
+  @computed
   get hasHeader() {
     return this.columns.some(({ renderHead }) => renderHead);
   }
 
+  @computed
   get hasFooter() {
     return this.columns.some(({ renderFoot }) => renderFoot);
   }
 
+  @computed
+  get editing() {
+    return !isEmpty(this.props.store.currentOne);
+  }
+
   componentDidMount() {
     const { store } = this.props;
 
@@ -183,7 +210,7 @@ export class RestTable<T extends DataObject> extends PureComponent<
         responsive = true,
         ...tableProps
       } = this.props,
-      { hasHeader, hasFooter, columns } = this;
+      { hasHeader, hasFooter, columns, editing } = this;
     const { indexKey, downloading, currentPage } = store;
 
     return (
@@ -202,7 +229,7 @@ export class RestTable<T extends DataObject> extends PureComponent<
           </thead>
         )}
         <tbody>
-          {downloading > 0 ? (
+          {!editing && downloading > 0 ? (
             <tr>
               <td className="text-center p-3" colSpan={columns.length}>
                 <Spinner />
@@ -238,71 +265,28 @@ export class RestTable<T extends DataObject> extends PureComponent<
     );
   }
 
-  handleSubmit = (ID: IDType) => async (event: FormEvent<HTMLFormElement>) => {
-    event.preventDefault();
-
-    const { store } = this.props;
-
-    await store.updateOne(formToJSON(event.currentTarget), ID);
-
-    store.clearCurrent();
-  };
-
-  renderInput = ({ key, type, renderHead }: Column<T>) => {
-    const { currentOne } = this.props.store;
-    const label =
-      typeof renderHead === 'function' ? renderHead?.(key) : renderHead || key;
-
-    return (
-      key && (
-        <FormField
-          className="mb-3"
-          key={key.toString()}
-          label={label}
-          type={type}
-          name={key.toString()}
-          defaultValue={currentOne[key]}
-        />
-      )
-    );
-  };
-
   renderDialog() {
     const { columns, store, translater } = this.props,
-      formId = uniqueID();
-    const { indexKey, uploading, currentOne } = store,
-      { t } = translater;
+      { editing } = this;
+    const { indexKey, currentOne } = store;
+
     const ID = currentOne[indexKey];
 
     return (
-      <Modal
-        show={!isEmpty(store.currentOne)}
-        onHide={() => store.clearCurrent()}
-      >
+      <Modal show={editing} onHide={() => store.clearCurrent()}>
         <Modal.Header closeButton>{ID}</Modal.Header>
 
         <Modal.Body>
-          <Form
-            id={formId}
-            onSubmit={this.handleSubmit(ID)}
-            onReset={() => store.clearCurrent()}
-          >
-            {columns.map(this.renderInput)}
-          </Form>
+          <RestForm
+            id={ID}
+            fields={columns.map(({ renderHead, ...field }) => ({
+              ...field,
+              renderLabel: renderHead,
+            }))}
+            store={store}
+            translater={translater}
+          />
         </Modal.Body>
-        <Modal.Footer>
-          <Button type="submit" form={formId} disabled={uploading > 0}>
-            {t('submit')}
-          </Button>
-          <Button
-            type="reset"
-            variant="danger"
-            form={formId}
-            disabled={uploading > 0}
-          >
-            {t('cancel')}
-          </Button>
-        </Modal.Footer>
       </Modal>
     );
   }
diff --git a/source/ScrollBoundary.tsx b/source/ScrollBoundary.tsx
index 3af09a7..500e359 100644
--- a/source/ScrollBoundary.tsx
+++ b/source/ScrollBoundary.tsx
@@ -6,55 +6,57 @@ export type EdgePosition = 'top' | 'bottom' | 'left' | 'right';
 export type TouchHandler = (edge: EdgePosition) => any;
 
 export interface ScrollBoundaryProps
-    extends Partial<Record<EdgePosition, ReactNode>> {
-    className?: string;
-    onTouch: TouchHandler;
+  extends Partial<Record<EdgePosition, ReactNode>> {
+  className?: string;
+  onTouch: TouchHandler;
 }
 
 function touch(edge: EdgePosition, onTouch: TouchHandler) {
-    return (node: HTMLElement | null) =>
-        node &&
-        new IntersectionObserver(
-            ([{ isIntersecting }]) => isIntersecting && onTouch(edge)
-        ).observe(node);
+  return (node: HTMLElement | null) =>
+    node &&
+    new IntersectionObserver(
+      ([{ isIntersecting }]) => isIntersecting && onTouch(edge),
+    ).observe(node);
 }
 
 export const ScrollBoundary: FC<ScrollBoundaryProps> = ({
-    className,
-    onTouch,
-    top,
-    left,
-    right,
-    bottom,
-    children
+  className,
+  onTouch,
+  top,
+  left,
+  right,
+  bottom,
+  children,
 }) => (
-    <div className={classNames('position-relative', className)}>
-        <div
-            className="position-absolute top-0 left-0 w-100"
-            ref={touch('top', onTouch)}
-        >
-            {top}
-        </div>
-        <div
-            className="position-absolute top-0 left-0 h-100"
-            ref={touch('left', onTouch)}
-        >
-            {left}
-        </div>
-
-        {children}
-
-        <div
-            className="position-absolute top-0 right-0 h-100"
-            ref={touch('right', onTouch)}
-        >
-            {right}
-        </div>
-        <div
-            className="position-absolute top-100 left-0 w-100"
-            ref={touch('bottom', onTouch)}
-        >
-            {bottom}
-        </div>
+  <div className={classNames('position-relative', className)}>
+    <div
+      className="position-absolute top-0 left-0 w-100"
+      ref={touch('top', onTouch)}
+    >
+      {top}
     </div>
+    <div
+      className="position-absolute top-0 left-0 h-100"
+      ref={touch('left', onTouch)}
+    >
+      {left}
+    </div>
+
+    {children}
+
+    <div
+      className="position-absolute top-0 right-0 h-100"
+      ref={touch('right', onTouch)}
+    >
+      {right}
+    </div>
+    <div
+      className="position-absolute top-100 left-0 w-100"
+      ref={touch('bottom', onTouch)}
+    >
+      {bottom}
+    </div>
+  </div>
 );
+
+ScrollBoundary.displayName = 'ScrollBoundary';
diff --git a/source/index.ts b/source/index.ts
index 63868ed..630a94e 100644
--- a/source/index.ts
+++ b/source/index.ts
@@ -1,5 +1,7 @@
-export * from './Pager';
+export * from './FilePreview';
 export * from './FormField';
+export * from './RestForm';
+export * from './Pager';
 export * from './RestTable';
 export * from './ScrollBoundary';
 export * from './ScrollList';
