Simplify sharing with built-in APIs and progressive enhancement

You’ve written a great post or produced a delightful website and now you want people to share it. In times gone by, you might be tempted to add a section like this:

A collection of social sharing buttons, which includes Twitter, Facebook, Instagram, Mastodon, Threads and WhatsApp

The problem is, these social sharing components are often not even touched by users, create potential privacy issues, affecting GDPR compliance and of course, third party sharing plugins can very negatively affect performance.

Not ideal!

A better approach

We’ve got two really useful, web platform tools available to us, that have fantastic browser support: the Web Share API and the Clipboard API.

The Web Share API allows us to present users with choice that matters to them, because it triggers the share mechanism of their device. For example, this is what I see when I trigger the web share API on my phone:

The sharing 'drawer' on iPhone which at the top, shows the people I've been texting or WhatsApping, followed by apps that support sharing. Following all of that is the iOS sharing controls, such as copy and add to reading list.

And this is what it looks like on my Mac:

A little Mac context menu that shows the last person I messaged, followed by options such as Airdrop, Messages and Notes.

The other tool — the Clipboard API — allows us to create a nice, simple “Copy Link” button. Copy and paste is frequently used by users and that makes sense, mainly because it’s easy. Copy link buttons also give users complete choice in terms of where they share your URL.

The only problem with these tools that we’re suggesting today is their usage reduces analytical measurements of sharing stats because you have no idea where the user shared your content. But, you are almost certainly going to increase the probability of a user actually sharing your content by opting to use those controls, so it feels like a good trade-off.

How to build it

I’m going to use our own site to build this sharing component. You can see it in action right now, at the bottom of the page.

Let’s have a quick think about some considerations.

  • This functionality requires JavaScript, so we have to treat this as a progressive enhancement.
  • We have a call to action at the end of articles. This takes preference in terms of visual hierarchy, so we need to be cautious about diluting that.
  • The Web Share API and Clipboard API require a secure connection, which we have already on the live site, but local testing might be tricky.
  • Browser support is mixed for these APIs, so we need to approach this again, with a progressive approach.

Step up, petite-vue

The petite-vue library is a tiny, ~ 6kb version of Vue JS that requires no build step and can be dropped on existing, non-Vue pages, such as our WordPress site. This is perfect for our use-case because this new functionality has to be fast and has to be light weight.

Sure, we could use a Web Component or just a snippet of standard JavaScript, but the combination of state changes and conditional rendering of elements — based on browser support — is much easier and elegant with a state-driven system like Vue. 6kb is a damn good trade-off too!

Core markup

Because we’re using Vue, we can integrate their specific attributes which will have no ill-effect for the user when JavaScript fails, but allow developers to quickly see how each element behaves on a HTML level.

The handy thing too, about petite-vue, is we can happily add our PHP logic and Vue logic in the same snippet. Here’s a simplified version of our share.php partial:

<div id="share" tabindex="-1" class="flow" v-scope="share({ url:'<?= get_permalink(); ?>', title: '<?= the_title(); ?>' })">
  <h2>Share with your network</h2>
  <div class="flow" v-if="!clipboardSupported && !webShareSupported">
    <p>Copy this link and send it to your friends 🙂</p>
    <p class="flow-space-2xs"><code><?= get_permalink(); ?></code></p>
  </div>
  <div :class="!noOptionsAvailable() ? 'cluster gutter-s' : null" hidden :hidden="noOptionsAvailable()">
    <div class="relative" v-if="webShareSupported">
      <button class="button" data-theme="ghost" @click="share">
        <!-- Share icon -->
        <span>Share</span>
      </button>
      <p role="alert" aria-live="polite" id="shareFeedback" class="context-alert" data-state="empty" :data-state="shareFeedback.length ? null : 'empty'">{{ shareFeedback }}</p>
    </div>
    <div class="relative" v-if="clipboardSupported">
      <button class="button" data-theme="ghost" @click="copyLink">
        <!-- Link icon -->
        <span>Copy link</span>
      </button>
      <p role="alert" aria-live="polite" id="copyFeedback" class="context-alert" data-state="empty" :data-state="copyFeedback.length ? null : 'empty'">{{ copyFeedback }}</p>
    </div>
  </div>
</div>

Let’s pick out some key snippets. The first one is the baseline experience, or as we like to call it, the minimum viable experience.

By default, the buttons — that require JavaScript to do anything — are within a hidden parent. This allows the browser to hide them (both visually and for screen readers) and also prevents focus accidentally finding itself in there.

What renders instead, is a little explainer paragraph and a <code> element that contains the current URL, allowing users to select it, copy it, then paste it wherever they please. That’s a pretty good minimum viable experience in our books.

This is the point where Vue steps in, so I’ll show the JavaScript code too, to help with the explainers.

import {createApp} from 'https://unpkg.com/petite-vue?module';

const share = ({title, url}) => {
  return {
    title,
    url,
    webShareSupported: navigator.share,
    clipboardSupported: navigator.clipboard,
    shareFeedback: '',
    copyFeedback: '',
    noOptionsAvailable() {
      return !this.clipboardSupported && !this.webShareSupported;
    },
    share() {
      navigator
        .share({
          title,
          url,
          text: title,
        })
        .then(() => {
          this.shareFeedback = 'Thanks!';

          setTimeout(() => {
            this.shareFeedback = '';
          }, 3000);
        })
        .catch((error) => console.error('Error sharing', error));
    },
    copyLink() {
      navigator.clipboard
        .writeText(url)
        .then(() => {
          this.copyFeedback = 'Link copied!';

          setTimeout(() => {
            this.copyFeedback = '';
          }, 3000);
        })
        .catch((error) => console.error(error));
    },
  };
};

createApp({share}).mount();

Right at the top of the component, we’re determining browser support for the Web Sharing API and the Clipboard API.

webShareSupported: navigator.share,
clipboardSupported: navigator.clipboard,

This is then hooked onto by the template in a few ways:

  1. The buttons only show if either of those APIs is supported
  2. Each specific button only shows if that specific API functionality is available to the browser
  3. When there is no JavaScript available and/or the browser doesn’t support either action, that initial minimum viable experience we set up is not affected

The noOptionsAvailable() method is used to determine whether either API is available to do most of the above.

Promises, promises

The most useful aspect about a lot of newer JavaScript APIs is that they are promise-based. It allows us to write a this > then > that pattern with the sharing methods.

share() {
  navigator
    .share({
      title,
      url,
      text: title,
    })
    .then(() => {
      this.shareFeedback = 'Thanks!';

      setTimeout(() => {
        this.shareFeedback = '';
      }, 3000);
    })
    .catch((error) => console.error('Error sharing', error));
},
copyLink() {
  navigator.clipboard
    .writeText(url)
    .then(() => {
      this.copyFeedback = 'Link copied!';

      setTimeout(() => {
        this.copyFeedback = '';
      }, 3000);
    })
    .catch((error) => console.error(error));
},

In both of the methods, we are using passed data — props from the component’s markup — to inform the APIs what we want the user to share.

The Web Share API gets more data for obvious reasons. The one bit I want to highlight there is we are using title for both title and text. This is because mainly on iOS, the text seems to work in place of title if it’s defined, which if say, you have a meta description passed in, makes for quite an awkward, clunky sharing experience for the user…

Both sharing methods in our component consume different datasets, but what they both do is set some feedback content. Because of Vue’s reactive nature, we can conditionally render the content in the little alerts visually, only if there is content. The elements always exist because they have role="alert" on them, which instructs screen readers to announce content changes. The aria-live="polite" — or as I call it, British mode — allows anything else to finish announcing to the screen reader user, rather than interrupting that flow.

The handy part of this micro-component is the CSS hook for visibility. If there is no content in the .context-alert component, the Vue component renders a data-state="empty" attribute, which is a CUBE CSS exception. Here’s what it looks like in our codebase:

.context-alert[data-state='empty'] {
  opacity: 0;
  transform: translateY(0.25em);
  transition: none;
}

This is the “hidden” state, which removes the transition set by our default state:

.context-alert {
  position: absolute;
  inset: auto 0 calc(100% + 0.5em) 0;
  padding: 0.25em;
  background: var(--color-primary);
  color: var(--color-light);
  font-weight: var(--font-bold);
  text-align: center;
  transition: opacity var(--transition-fade) 200ms,
    transform var(--transition-bounce-fast) 200ms;
}

What this allows is a smooth transition when content is defined, but then a snappy removal. It’s part preference at our end, but also useful for elements to get the hell out of the way, rather than painfully transitioning away. The Vue component clears the alert text after 3 seconds, so all of this ties together seamlessly.

Wrapping up

You be thinking that damn, that’s a lot of effort for a sharing component. We don’t think so! It’s really important to build experiences that work for everyone, so sweating the details feels like low effort in context.

Always remember, the optimal experience isn’t the shiniest version from your Figma comps. It’s the version that works for that specific user in that specific instance. No one will ever complain about getting a good experience and certainly won’t wonder “am I getting the optimal experience”, because they already are getting the optimal experience with progressive enhancement.


Thanks for reading this Little Design Tip. You can subscribe to these with this RSS feed, or subscribe to all of our posts on the blog with this RSS feed.


Share with your network

Copy this link and send it to your friends 🙂

https://set.studio/simplify-sharing-with-built-in-apis-and-progressive-enhancement/

Hey there, we are Set Studio.

We’re a small but nimble, distributed design and development studio

The web is truly global, so our rare combination of highly technical and highly creative designers produce stunning websites that work for everyone.