Skip to content

Modal

The Modal component can be used to create dialogs, drawers, or anything else that needs to display new content to a user without navigating to a new page.

Features

  • Provides a backdrop component to prevent interaction with the rest of the app.
  • Page scrolling is disabled while open.
  • Ensures the tab key maintains focus within the modal.
  • Adds appropriate ARIA attributes and roles.

INFO

The animations for Modal are written in Modals.vue and use the <TransitionGroup> component from Vue.

Basic Usage

vue
<script setup lang="ts">
  import useModals from '@leaflink/stash/useModals';
  import Button from '@leaflink/stash/components/Button/Button.vue';
  import BasicModal from './BasicModal.vue';

  const modals = useModals();
</script>

<template>
  <Button @click="modals.open({ component: BasicModal })">
    Open Basic Modal
  </Button>
</template>
vue
<script setup lang="ts">
  import Modal from '../../../src/components/Modal/Modal.vue';
</script>

<template>
  <Modal title="Your Modal Title">
    <h3 class="tw-mb-5">This is a basic modal.</h3>
    <p>
      Service worker lazy eval native singleton package manager functional programming. Shareware container S3 quick
      sort lazy eval programmer. Bootcamp ICO modern bundle LGTM distributed callback S3 module. Developer avocado
      virtual DOM accessibility architecture variable pair programming.
    </p>
  </Modal>
</template>

The Modal component makes it easier to render buttons in the footer by using the #actions slot.

vue
<script setup lang="ts">
  import Button from '../../../src/components/Button/Button.vue';
  import Modal from '../../../src/components/Modal/Modal.vue';

  const emit = defineEmits<{
    (e: 'dismiss'): void;
  }>();

  function onSave() {
    alert('Saved!');
    emit('dismiss');
  }
</script>

<template>
  <Modal title="Your Modal Title" @dismiss="emit('dismiss')">
    <h3 class="tw-mb-5">This is a modal with actions.</h3>
    <p>
      Concurrency meta-programming composition configuration whiteboard cowboy coding looks good to me big O perf
      matters. Lazy directed acyclic graph streams branch containerized browser AWS CSS-in-JS yarn. Key-value reflog
      hardcoded mock idiosyncratic contexts model ship it bike-shedding npm browser. UI clean code views cache homebrew
      scale CSS-in-JS functional programming JSX. Hacker News tabs vs spaces model view-model Babel parent var sudo
      static.
    </p>
    <template #actions>
      <Button secondary @click="emit('dismiss')">Cancel</Button>
      <Button @click="onSave">Save</Button>
    </template>
  </Modal>
</template>

Contrasting Background

To give the modal body a gray background color, use the contrast prop.

vue
<script setup lang="ts">
  import Modal from '../../../src/components/Modal/Modal.vue';
</script>

<template>
  <Modal title="Your Modal Title" contrast>
    <h3 class="tw-mb-5">This is a modal with a contrasting gray background.</h3>
    <p>
      Whiteboard Byzantine fault tolerance subclass presenter convention domain. Imperative-mood documentation driven
      SRE lazy load pattern reflection. Test-driven convention UI CSS-in-JS distributed GraphQL resolve static typing.
      Mechanical keyboard YAML abstraction backend free as in beer Twitter resolve.
    </p>
  </Modal>
</template>

Instead of using the #actions slot, you can use the #footer slot to render whatever you want in the modal footer.

vue
<script setup lang="ts">
  import Button from '../../../src/components/Button/Button.vue';
  import Modal from '../../../src/components/Modal/Modal.vue';

  const emit = defineEmits<{
    (e: 'dismiss'): void;
  }>();

  function onReset() {
    alert('Reset!');
    emit('dismiss');
  }

  function onSave() {
    alert('Saved!');
    emit('dismiss');
  }
</script>

<template>
  <Modal title="Your Modal Title" @dismiss="emit('dismiss')">
    <h3 class="tw-mb-5">This is a modal with a custom footer.</h3>
    <p>
      UI waterfall protected pair programming database proof of stake Slack blockchain. REST stack DevTools contribution
      configuration developer freelancer. REST Edge private Ruby devops cache IoT. Minimum viable product Hacker News
      big O cache sudo reactive Internet Explorer object library. Lazy eval Stack Overflow Backbone.js test double lazy
      load senior-engineer accessibility.
    </p>
    <template #footer>
      <div class="tw-flex tw-flex-col tw-gap-3 lg:tw-flex-row lg:tw-justify-between lg:tw-gap-6">
        <Button inline @click="onReset">Reset</Button>
        <div class="tw-flex tw-flex-col tw-gap-3 lg:tw-flex-row lg:tw-gap-6">
          <Button secondary @click="emit('dismiss')">Cancel</Button>
          <Button @click="onSave">Save</Button>
        </div>
      </div>
    </template>
  </Modal>
</template>

Header Action

You can add a button to the left of the modal title with the #header-action slot.

vue
<script setup lang="ts">
  import Button from '../../../src/components/Button/Button.vue';
  import Icon from '../../../src/components/Icon/Icon.vue';
  import Modal from '../../../src/components/Modal/Modal.vue';

  const emit = defineEmits<{
    (e: 'dismiss'): void;
  }>();

  function onBack() {
    alert('Back clicked!');
    emit('dismiss');
  }
</script>

<template>
  <Modal title="Your Modal Title" @dismiss="emit('dismiss')">
    <template #headerAction>
      <Button icon @click="onBack">
        <Icon class="tw-text-white" name="chevron-left" />
      </Button>
    </template>
    <h3 class="tw-mb-5">This modal has a header action.</h3>
    <p>
      Yarn Chrome proof of stake RSS feed REST Linux class. Branch cache antipattern constant microservices sudo UX
      markup frame rate quick sort. Cloud Github interface distributed engineer Babel duck typing. SOAP a place for
      everything AI build tool API tree shaking Backbone.js scrum master compile. Bootcamp quick sort XML driver
      ecommerce platform infrastructure fault tolerant raspberry pi API budget.
    </p>
  </Modal>
</template>

Size

To adjust the size of the modal, use the size prop.

vue
<script setup lang="ts">
  import Modal from '../../../src/components/Modal/Modal.vue';
</script>

<template>
  <Modal title="Your Modal Title" size="narrow">
    <h3 class="tw-mb-5">This is a narrow modal.</h3>
    <p>
      Package manager webpack legacy Internet Explorer static typing state OTP. Bubble sort compilation API compression
      Reddit FP ecommerce platform serverless Agile. Merge sort graph casting container Reddit i subclass static typing
      mock ecommerce platform. Diversity and inclusion junior private YAML proof of stake budget cross-post. Quick sort
      OTP maintainable const graph tabs vs spaces idiosyncratic contexts engineer documentation driven.
    </p>
  </Modal>
</template>
vue
<script setup lang="ts">
  import Modal from '../../../src/components/Modal/Modal.vue';
</script>

<template>
  <Modal title="Your Modal Title" size="wide">
    <h3 class="tw-mb-5">This is a wide modal.</h3>
    <p class="tw-mb-5">
      Grep git waterfall command-line junior CSS-in-JS homebrew. Elixir continuous integration naming things ELF CS
      degree rebase consensus presenter keycaps emoji. Branch programmer JSX whiteboard duck typing const. Idiosyncratic
      contexts concurrency mobile app scale child CLI proof of stake junior. Firefox Angular compiler pivot imagemagick
      resolve architecture.
    </p>
    <p>
      Blog controller cross-post concurrent free as in beer responsive configuration DAG whiteboard IoT. Slack bootcamp
      Reddit transpile public XML design reflection Chrome. Callback hell dog-piling directed acyclic graph frame rate
      concurrent ecommerce platform legacy AI internet button test double.
    </p>
  </Modal>
</template>

Scrollable Content

Use the scrollable prop to prevent the modal from expanding past the screen edge when the content inside the modal is long enough. The scrollable prop also adds a scrollbar within the modal body.

vue
<script setup lang="ts">
  import { defineAsyncComponent, ref } from 'vue';

  import Button from '../../../src/components/Button/Button.vue';
  import CurrencyInput from '../../../src/components/CurrencyInput/CurrencyInput.vue';
  import Field from '../../../src/components/Field/Field.vue';
  import Input from '../../../src/components/Input/Input.vue';
  import Modal from '../../../src/components/Modal/Modal.vue';
  import RadioGroup from '../../../src/components/RadioGroup/RadioGroup.vue';
  import RadioNew from '../../../src/components/RadioNew/RadioNew.vue';
  import Select from '../../../src/components/Select/Select.vue';
  import Switch from '../../../src/components/Switch/Switch.vue';

  // Since Quill uses the browser api, defineClientComponent can lazy load the Text Editor in a non SSR environment
  const TextEditor = defineAsyncComponent(() => import('../../../src/components/TextEditor/TextEditor.vue'));

  const emit = defineEmits<{
    close: [];
    dismiss: [];
  }>();

  const formState = ref({
    sampleRequests: false,
    sellInMultiples: false,
    showInventoryToBuyers: false,
    allowOrderInFractionalQuantities: false,
  });

  const wholesalePrice = ref();
  const retailPrice = ref();
  const salePrice = ref();
</script>

<template>
  <Modal title="Add Product Template" scrollable @dismiss="emit('dismiss')">
    <form @submit.prevent>
      <Field id="template-name" label="Template name" class="tw-mb-6" is-required>
        <Input placeholder="Input text" />
      </Field>

      <div class="tw-grid md:tw-grid-cols-2 md:tw-gap-6">
        <Select label="Brand" class="tw-mb-6" placeholder="---" :options="[]" is-required single />
        <Select label="Product line" class="tw-mb-6" :options="[]" single />
      </div>

      <div class="tw-grid md:tw-grid-cols-2 md:tw-gap-6">
        <Select label="Licenses" class="tw-mb-6" placeholder="---" :options="[]" is-required single />

        <Select
          display-by="displayValue"
          track-by="value"
          label="Status"
          class="tw-mb-6"
          placeholder="---"
          :options="[]"
          is-required
          hide-search
          single
        />
      </div>

      <TextEditor label="Description" class="tw-mb-6" />

      <div class="tw-grid md:tw-grid-cols-3 md:tw-gap-6">
        <Field label="Wholesale Price" class="tw-mb-6" is-required>
          <CurrencyInput v-model="wholesalePrice" placeholder="0.00" />
        </Field>

        <CurrencyInput v-model="retailPrice" label="Retail (MSRP) Price" placeholder="0.00" class="tw-mb-6" />

        <CurrencyInput v-model="salePrice" label="Sale Price" placeholder="0.00" class="tw-mb-6" />
      </div>

      <div class="tw-grid md:tw-grid-cols-3 md:tw-gap-6">
        <Switch v-model:checked="formState.sampleRequests" class="tw-mb-6" label="Sample requests" />

        <Switch v-model:checked="formState.sellInMultiples" class="tw-mb-6" label="Sell in multiples" />

        <Input label="Individual units per case" class="tw-mb-6" placeholder="1.0" type="number" />
      </div>

      <div class="tw-grid md:tw-grid-cols-2 md:tw-gap-6">
        <Select label="Category" class="tw-mb-6" placeholder="---" :options="[]" is-required single />

        <Select label="Subcategory" class="tw-mb-6" placeholder="---" :options="[]" is-required single />
      </div>

      <div class="tw-flex tw-flex-col md:tw-flex-row md:tw-gap-6">
        <Field class="tw-flex-1" label="Unit of measure" fieldset is-required>
          <div class="tw-grid md:tw-grid-cols-2 md:tw-gap-6">
            <Select class="tw-mb-6" placeholder="0" display-by="label" :options="[]" hide-search single />
            <Select class="tw-mb-6" placeholder="Measure" hide-search single />
          </div>
        </Field>

        <Field class="tw-mb-6" hint-text="Per individual unit" label="Grams" is-required>
          <template #default="{ fieldId }">
            <Input :id="fieldId" placeholder="0" type="number" disabled />
          </template>
        </Field>
      </div>

      <Select
        label="Classification"
        class="tw-mb-6"
        placeholder="---"
        display-by="displayValue"
        track-by="value"
        :options="[]"
        hide-search
        is-required
        single
      />

      <RadioGroup
        class="tw-mb-6 [&>div]:tw-gap-0"
        label="How do you want to manage your inventory?"
        name="inventory-management"
        is-required
        full-width
      >
        <Field class="tw-w-full tw-rounded-t-[4px] tw-border tw-border-b-0 tw-border-ice-500 tw-p-3">
          <template #hint>
            <span class="tw-ml-8">Each variety has its own inventory tracked separately</span>
          </template>

          <RadioNew id="managed" class="tw-text-ice-900" label="Managed" value="managed" />
        </Field>
        <Field class="tw-w-full tw-border tw-border-b-0 tw-border-ice-500 tw-p-3">
          <template #hint>
            <span class="tw-ml-8">All varieties pull from the same inventory quantity</span>
          </template>

          <RadioNew id="inherited" class="tw-text-ice-900" label="Inherited" value="inherited" />
        </Field>
        <Field class="tw-w-full tw-rounded-b-[4px] tw-border tw-border-ice-500 tw-p-3">
          <template #hint>
            <span class="tw-ml-8">Products have no limit and inventory is not tracked</span>
          </template>

          <RadioNew id="unlimited" class="tw-text-ice-900" label="Unlimited" value="unlimited" />
        </Field>
      </RadioGroup>

      <div class="tw-grid md:tw-grid-cols-2 md:tw-gap-6">
        <Switch v-model:checked="formState.showInventoryToBuyers" class="tw-mb-6" label="Show inventory to buyers" />
        <Switch
          v-model:checked="formState.allowOrderInFractionalQuantities"
          class="tw-mb-6"
          label="Allow order in fractional quantities"
        />
      </div>

      <Field class="tw-flex-1" label="Order threshold" fieldset>
        <div class="tw-grid tw-items-end md:tw-grid-cols-2 md:tw-gap-6">
          <Input class="tw-mb-6" placeholder="0.00" type="number" :hint-text="`Min order qty`" />
          <Input class="tw-mb-6" placeholder="0.00" type="number" :hint-text="`Max order qty`" />
        </div>
      </Field>

      <div class="tw-flex tw-flex-col md:tw-flex-row md:tw-gap-6">
        <Input label="Increasing qty" hint-text="in units" class="tw-mb-6" placeholder="0" type="number" />

        <Select
          class="tw-mb-6 tw-flex-1"
          label="Action taken"
          display-by="displayValue"
          track-by="value"
          placeholder="---"
          :options="[]"
          hide-search
          single
        />
      </div>
    </form>

    <template #footer>
      <div class="tw-flex tw-flex-col tw-gap-3 md:tw-flex-row md:tw-justify-end">
        <Button class="md:tw-w-[105px] md:tw-min-w-auto" secondary @click="emit('close')">Cancel</Button>
        <Button @click="emit('close')">Add Template</Button>
      </div>
    </template>
  </Modal>
</template>

Drawer

Modal can be used to create a "drawer", which is a sidebar that slides in & out from one side of the screen. Use the position prop to enable the drawer pattern.

vue
<script setup lang="ts">
  import Modal from '../../../src/components/Modal/Modal.vue';
</script>

<template>
  <Modal title="Your Drawer Title" position="right" size="narrow">
    <h3 class="tw-mb-5">This is a modal drawer that opens from the right on large screens.</h3>
    <p class="tw-mb-5">The HTML killer app is down, transpile the atomic browser so we can inject the ELF pattern!</p>
    <p class="tw-mb-5">I'll clear the reactive RPC app, that should flexbox the AI platform!</p>
    <p class="tw-mb-5">I'll integrate the documentation-driven S3 compiler, that should transaction the TOML kernel!</p>
    <p class="tw-mb-5">Try to inject the IRC budget, maybe it will compile the domain-specific instance!</p>
    <p class="tw-mb-5">
      So duck-typing the webpack won't do anything, we need to transact the fullstack SOAP engineer!
    </p>
  </Modal>
</template>

Promo modals

Combining the featuredContent slot, and the hideHeader prop, you can create promotional modals. The close button will still appear in the top right.

vue
<script setup lang="ts">
  import Button from '../../../src/components/Button/Button.vue';
  import Modal from '../../../src/components/Modal/Modal.vue';

  const emit = defineEmits<{
    (e: 'dismiss'): void;
  }>();

  function onSave() {
    alert('Saved!');
    emit('dismiss');
  }
</script>

<template>
  <Modal hide-header @dismiss="emit('dismiss')">
    <template #featured-content>
      <img src="./assets/promoExample.svg" class="tw-block tw-w-full" />
    </template>
    <h3 class="tw-mb-5">This is a promo modal.</h3>
    <p>
      Concurrency meta-programming composition configuration whiteboard cowboy coding looks good to me big O perf
      matters. Lazy directed acyclic graph streams branch containerized browser AWS CSS-in-JS yarn. Key-value reflog
      hardcoded mock idiosyncratic contexts model ship it bike-shedding npm browser. UI clean code views cache homebrew
      scale CSS-in-JS functional programming JSX. Hacker News tabs vs spaces model view-model Babel parent var sudo
      static.
    </p>
    <template #actions>
      <Button secondary @click="emit('dismiss')">Cancel</Button>
      <Button @click="onSave">Save</Button>
    </template>
  </Modal>
</template>

To support images of with any color background, you can use the closeButtonColorClass prop to pass in a tailwind text color class.

TIP

You can set your text color's opacity by appending any of our color classes with /{percentage}. Example: tw-text-white/50 will create color: rgba(255,255,255,0.5)

vue
<script setup lang="ts">
  import Button from '../../../src/components/Button/Button.vue';
  import Modal from '../../../src/components/Modal/Modal.vue';

  const emit = defineEmits<{
    (e: 'dismiss'): void;
  }>();

  function onSave() {
    alert('Saved!');
    emit('dismiss');
  }
</script>

<template>
  <Modal hide-header @dismiss="emit('dismiss')">
    <template #featured-content>
      <img src="./assets/promoExample.svg" class="tw-block tw-w-full" />
    </template>
    <h3 class="tw-mb-5">This is a promo modal.</h3>
    <p>
      Concurrency meta-programming composition configuration whiteboard cowboy coding looks good to me big O perf
      matters. Lazy directed acyclic graph streams branch containerized browser AWS CSS-in-JS yarn. Key-value reflog
      hardcoded mock idiosyncratic contexts model ship it bike-shedding npm browser. UI clean code views cache homebrew
      scale CSS-in-JS functional programming JSX. Hacker News tabs vs spaces model view-model Babel parent var sudo
      static.
    </p>
    <template #actions>
      <Button secondary @click="emit('dismiss')">Cancel</Button>
      <Button @click="onSave">Save</Button>
    </template>
  </Modal>
</template>

Hides the Help widget

The Modal component will auto-hide the Zendesk "help widget launcher" (if it exists on the page) when a modal opens and un-hides the widget when the modal closes. Without this behavior, the help widget launcher can block the action buttons when the modal has a position of "right":

Help widget covering the modal actions

API

See the documentation below for a complete reference to all the props and classes available to the components mentioned here.