Share button

Opens the device's own share sheet where there is one, and copies the link where there is not.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add share-button
pnpm dlx ermineui add share-button
yarn dlx ermineui add share-button
bunx ermineui add share-button

That copies 2 files into src/components/ui/share-button/, and the 1 component it is built from. Files you already have are kept.

Use

import { ShareButton } from '@/components/ui/share-button'
<ShareButton url="https://example.com/post" title="A post" text="Worth a read." />
<QrCode value="https://example.com/post" :size="160" />

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
ShareButton.vuesrc/components/ui/share-button/ShareButton.vue
index.tssrc/components/ui/share-button/index.ts

It is built from: Button. These are added with it.

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

ShareButton.vue 84 lines
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { Button } from '../button'

const props = withDefaults(
  defineProps<{
    /** The link to share. Defaults to the page's own address. */
    url?: string
    /** A title for the thing being shared. Apps use it as a subject line or heading. */
    title?: string
    /** A sentence to go with the link. */
    text?: string
    /** Shown for a moment after the link is copied, on devices with no share sheet. */
    copiedLabel?: string
    variant?: 'default' | 'secondary' | 'outline' | 'ghost'
    size?: 'sm' | 'md' | 'lg'
  }>(),
  { copiedLabel: 'Link copied', variant: 'outline', size: 'md' },
)

const emit = defineEmits<{
  /** The share sheet was used and something was chosen. */
  share: []
  /** There was no share sheet, so the link went to the clipboard. */
  copy: [url: string]
  /** Neither worked: no permission, or an insecure page. */
  error: [error: unknown]
}>()

const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
onBeforeUnmount(() => clearTimeout(timer))

async function share() {
  const url = props.url ?? window.location.href
  const data = { url, title: props.title, text: props.text }
  try {
    // Phones, and some desktop browsers, have a native sheet listing the apps that can take a link.
    if (typeof navigator.share === 'function' && (navigator.canShare?.(data) ?? true)) {
      await navigator.share(data)
      emit('share')
      return
    }
    await navigator.clipboard.writeText(url)
    copied.value = true
    emit('copy', url)
    clearTimeout(timer)
    timer = setTimeout(() => (copied.value = false), 2000)
  } catch (error) {
    // Closing the share sheet without choosing is not an error, just a change of mind.
    if (error instanceof DOMException && error.name === 'AbortError') return
    emit('error', error)
  }
}
</script>

<template>
  <Button class="er-share-button" :variant="variant" :size="size" :data-copied="copied ? '' : undefined" @click="share">
    <svg v-if="copied" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5" />
    </svg>
    <svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M12 15V3M7 8l5-5 5 5M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
    </svg>
    <template v-if="copied">{{ copiedLabel }}</template>
    <slot v-else>Share</slot>
    <!-- The label swap is silent to a screen reader, so the confirmation is also announced. -->
    <span class="er-share-button-status" role="status">{{ copied ? copiedLabel : '' }}</span>
  </Button>
</template>

<style>
@layer components {
  .er-share-button-status {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}
</style>
index.ts 3 lines
/** Share button — opens the device's own share sheet where there is one, and copies the link where there is not. */
export { default as ShareButton } from './ShareButton.vue'