Goodies

Quill

Allow users to create rich text content with the Quill editor.

Getting Started

Installation

To get started, you can install the package with the following command:

Create CSS File

In order to make the editor match the design of this website (and the whole shadcn/ui theme), I had to add this css file: You should copy this and add it to your project.

css

Quill CSS Overrides

@import "@vueup/vue-quill/dist/vue-quill.snow.css";
@import "@vueup/vue-quill/dist/vue-quill.bubble.css";

.ql-toolbar {
  &.ql-snow {
    border-color: var(--color-border);
    border-top-left-radius: var(--radius-md);
    border-top-right-radius: var(--radius-md);
    font-family: var(--font-sans);
  }
  &.ql-snow {
    .ql-stroke {
      stroke: var(--color-muted-foreground);
    }
    .ql-fill {
      fill: var(--color-muted-foreground);
    }
    button {
      margin-inline: calc(var(--spacing) * 0.5);
      border-radius: var(--radius-sm);

      &:hover {
        background-color: var(--color-muted);
        color: var(--color-foreground);
      }
      &:hover {
        .ql-fill {
          fill: var(--color-foreground);
        }
        .ql-stroke {
          stroke: var(--color-foreground);
        }
      }
      .ql-stroke {
        stroke: var(--color-muted-foreground);
      }
      &.ql-active {
        background-color: var(--color-primary);
        color: var(--color-primary-foreground);

        .ql-stroke {
          stroke: var(--color-primary-foreground);
        }
        .ql-fill {
          fill: var(--color-primary-foreground);
        }
      }
    }
    .ql-formats {
      svg,
      .ql-picker-label,
      .ql-picker {
        color: var(--color-muted-foreground);
      }
      button {
        margin-inline: calc(var(--spacing) * 0.5);
        border-radius: var(--radius-sm);

        &:hover {
          background-color: var(--color-muted);
          color: var(--color-foreground);
        }
        &:hover {
          .ql-fill {
            fill: var(--color-foreground);
          }
          .ql-stroke {
            stroke: var(--color-foreground);
          }
        }
        .ql-fill {
          fill: var(--color-muted-foreground);
        }
        .ql-stroke {
          stroke: var(--color-muted-foreground);
        }
        &.ql-active {
          background-color: var(--color-primary);
          color: var(--color-primary-foreground);

          .ql-fill {
            fill: var(--color-primary-foreground);
          }
          .ql-stroke {
            stroke: var(--color-primary-foreground);
          }
        }
      }
      .ql-picker {
        border-radius: var(--radius-sm);

        .ql-picker-options {
          margin-top: calc(var(--spacing) * 1);
          border-color: var(--color-border);
          border-radius: var(--radius-sm);
          background-color: var(--color-card);
          padding: calc(var(--spacing) * 1);

          .ql-picker-item {
            border-radius: var(--radius-sm);

            &:hover {
              background-color: var(--color-muted);
              color: var(--color-foreground);
            }

            &.ql-selected {
              background-color: var(--color-primary);
              color: var(--color-primary-foreground);
            }
          }
        }
      }
      .ql-align,
      .ql-color-picker {
        &:hover {
          .ql-fill {
            fill: var(--color-foreground);
          }
          .ql-stroke {
            stroke: var(--color-foreground);
          }
        }
        .ql-picker-options {
          margin-top: calc(var(--spacing) * 1);
          border-color: var(--color-border);
          border-radius: var(--radius-sm);
          background-color: var(--color-card);
          padding: calc(var(--spacing) * 1);

          .ql-picker-item {
            &.ql-selected {
              background-color: var(--color-primary);
              color: var(--color-primary-foreground);

              .ql-stroke {
                stroke: var(--color-primary-foreground);
              }
            }
          }
        }
      }
      .ql-picker-label {
        &:hover {
          border-radius: var(--radius-sm);
          background-color: var(--color-muted);
          color: var(--color-foreground);
        }

        &.ql-active {
          border-radius: var(--radius-sm);
          background-color: var(--color-primary);
          color: var(--color-primary-foreground);

          .ql-stroke {
            stroke: var(--color-primary-foreground);
          }
        }
      }
      .ql-stroke {
        stroke: var(--color-muted-foreground);
      }
      .ql-expanded {
        .ql-picker-label {
          border-color: var(--color-border);
          border-radius: var(--radius-sm);
        }
      }
    }
  }
}

.ql-container {
  min-height: 150px;
  background-color: transparent;
  font-family: var(--font-sans);
  font-size: var(--text-sm);
  line-height: var(--text-sm--line-height);

  &:focus-within {
    border-color: var(--color-ring) !important;
    box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-ring) 50%, transparent);
  }

  &:is(.dark *) {
    background-color: color-mix(in oklab, var(--color-input) 30%, transparent);
  }

  &.ql-snow {
    a {
      color: var(--color-sky-500);

      &:hover {
        color: var(--color-sky-500);
      }
    }

    border-color: var(--color-border);
    border-bottom-right-radius: var(--radius-md);
    border-bottom-left-radius: var(--radius-md);
  }
  .ql-editor {
    min-height: 150px;

    .ql-font-monospace {
      font-family: var(--font-mono);
    }
    &.ql-blank {
      &:before {
        color: var(--color-muted-foreground);
        font-style: normal;
      }
    }
  }
  .ql-tooltip {
    z-index: 9999;
    border-color: var(--color-border);
    border-radius: var(--radius-sm);
    background-color: var(--color-card);
    padding-inline: calc(var(--spacing) * 4);
    padding-block: calc(var(--spacing) * 2);
    color: var(--color-card-foreground);
    font-size: var(--text-sm);
    line-height: var(--text-sm--line-height);
    box-shadow: var(--shadow);

    &::before {
      cursor: pointer;
      font-weight: var(--font-weight-medium);
    }

    input[type="text"] {
      height: calc(var(--spacing) * 8);
      width: 200px;
      border-color: var(--color-border);
      border-radius: var(--radius-sm);
      background-color: color-mix(in oklab, var(--color-muted) 30%, transparent);
      padding: calc(var(--spacing) * 2);
      color: var(--color-foreground);
      font-size: var(--text-sm);
      line-height: var(--text-sm--line-height);

      &:focus {
        outline: none;
        box-shadow: 0 0 0 1px var(--color-ring);
      }
    }
    .ql-preview {
      font-size: var(--text-sm);
      line-height: 26px;
      text-decoration-line: underline;
      text-underline-offset: 2px;
    }
    .ql-remove {
      color: var(--color-destructive);

      &:hover {
        color: var(--color-destructive);
      }
    }
  }
}

.ql-container.ql-bubble {
  border-style: solid;
  border-width: 1px;
  border-radius: var(--radius-md);

  .ql-tooltip {
    z-index: 9999;
    border-style: solid;
    border-width: 1px;
    border-color: var(--color-border);
    border-radius: var(--radius-lg);
    background-color: var(--color-card);
    padding-inline: calc(var(--spacing) * 4);
    padding-block: calc(var(--spacing) * 2);
    color: var(--color-card-foreground);
    font-size: var(--text-sm);
    line-height: var(--text-sm--line-height);
    box-shadow: var(--shadow);

    &::before {
      cursor: pointer;
      font-weight: var(--font-weight-medium);
    }

    input[type="text"] {
      height: calc(var(--spacing) * 8);
      width: 200px;
      border-color: var(--color-border);
      border-radius: var(--radius-sm);
      background-color: color-mix(in oklab, var(--color-muted) 30%, transparent);
      padding: calc(var(--spacing) * 2);
      color: var(--color-foreground);
      font-size: var(--text-sm);
      line-height: var(--text-sm--line-height);

      &:focus {
        outline: none;
        box-shadow: 0 0 0 1px var(--color-ring);
      }
    }
    .ql-preview {
      font-size: var(--text-sm);
      line-height: 26px;
      text-decoration-line: underline;
      text-underline-offset: 2px;
    }
    .ql-remove {
      color: var(--color-destructive);

      &:hover {
        color: var(--color-destructive);
      }
    }
    &:not(.ql-flip) .ql-tooltip-arrow {
      border-bottom-color: var(--color-border);
    }
    .ql-toolbar {
      .ql-stroke {
        stroke: var(--color-muted-foreground);
      }
      .ql-fill {
        fill: var(--color-muted-foreground);
      }

      button {
        margin-inline: calc(var(--spacing) * 0.5);
        border-radius: var(--radius-sm);

        &:hover {
          background-color: var(--color-muted);
          color: var(--color-foreground);
        }
        &:hover {
          .ql-fill {
            fill: var(--color-foreground);
          }
          .ql-stroke {
            stroke: var(--color-foreground);
          }
        }
        .ql-stroke {
          stroke: var(--color-muted-foreground);
        }
        &.ql-active {
          background-color: var(--color-primary);
          color: var(--color-primary-foreground);

          .ql-stroke {
            stroke: var(--color-primary-foreground);
          }
          .ql-fill {
            fill: var(--color-primary-foreground);
          }
        }
      }
      .ql-formats {
        svg,
        .ql-picker-label,
        .ql-picker {
          color: var(--color-muted-foreground);
        }
        button {
          margin-inline: calc(var(--spacing) * 0.5);
          border-radius: var(--radius-sm);

          &:hover {
            background-color: var(--color-muted);
            color: var(--color-foreground);
          }
          &:hover {
            .ql-fill {
              fill: var(--color-foreground);
            }
            .ql-stroke {
              stroke: var(--color-foreground);
            }
          }
          .ql-fill {
            fill: var(--color-muted-foreground);
          }
          .ql-stroke {
            stroke: var(--color-muted-foreground);
          }
          &.ql-active {
            background-color: var(--color-primary);
            color: var(--color-primary-foreground);

            .ql-fill {
              fill: var(--color-primary-foreground);
            }
            .ql-stroke {
              stroke: var(--color-primary-foreground);
            }
          }
        }
        .ql-picker {
          border-radius: var(--radius-sm);

          .ql-picker-options {
            margin-top: calc(var(--spacing) * 1);
            border-style: solid;
            border-width: 1px;
            border-color: var(--color-border);
            border-radius: var(--radius-sm);
            background-color: var(--color-card);
            padding: calc(var(--spacing) * 1);

            .ql-picker-item {
              border-radius: var(--radius-sm);
              padding-inline: calc(var(--spacing) * 1);

              &:hover {
                background-color: var(--color-muted);
                color: var(--color-foreground);
              }

              &.ql-selected {
                background-color: var(--color-primary);
                color: var(--color-primary-foreground);
              }
            }
          }
        }
        .ql-align,
        .ql-color-picker {
          &:hover {
            .ql-fill {
              fill: var(--color-foreground);
            }
            .ql-stroke {
              stroke: var(--color-foreground);
            }
          }
          .ql-picker-options {
            margin-top: calc(var(--spacing) * 1);
            border-style: solid;
            border-width: 1px;
            border-color: var(--color-border);
            border-radius: var(--radius-sm);
            background-color: var(--color-card);
            padding: calc(var(--spacing) * 1);

            .ql-picker-item {
              &.ql-selected {
                background-color: var(--color-primary);
                color: var(--color-primary-foreground);

                .ql-stroke {
                  stroke: var(--color-primary-foreground);
                }
              }
            }
          }
        }
        .ql-picker-label {
          &:hover {
            border-radius: var(--radius-sm);
            background-color: var(--color-muted);
            color: var(--color-foreground);
          }

          &.ql-active {
            border-radius: var(--radius-sm);
            background-color: var(--color-primary);
            color: var(--color-primary-foreground);

            .ql-stroke {
              stroke: var(--color-primary-foreground);
            }
          }
        }
        .ql-stroke {
          stroke: var(--color-muted-foreground);
        }
        .ql-expanded {
          .ql-picker-label {
            border-color: var(--color-border);
            border-radius: var(--radius-sm);
          }
        }
      }
    }
  }
}

Usage

Basic

Here is a basic example of how to use the Quill component. We are using a technique called Slot Forwarding so that if the developer wants to create a component and pass through the toolbar slot, they can do so.

Toolbar

We can add our custom toolbar configuration by using the toolbar prop.

Slot - Toolbar

Another way of customizing the toolbar is by using the toolbar slot. This way, we can create a custom toolbar with our own components.

Bubble Theme

We can pass the bubble value to the theme prop to use the snow theme.

You have to select something in the editor to see the toolbar.

Module

We can pass an object or an array of objects to the module prop to use any Quill module.

Something like this:

<script lang="ts" setup>
  import { QuillEditor } from "@vueup/vue-quill";

  type SingleModule = {
    name: string;
    module: any;
    options?: any;
  };
  type ModuleObject = SingleModule | SingleModule[];

  const modules = ref<ModuleObject | null>(null);

  onMounted(async () => {
    const BlotFormatter = (await import("quill-blot-formatter")).default;
    modules.value = {
      name: "blotFormatter",
      module: BlotFormatter,
      options: {
        /* options */
      },
    };
  });
</script>

Upload an image to see the module in action.