Skip to content
Welcome to vote for your favorite component! You can also tell me anything you want to say! (*´∀`)~♥

Cursor Sidekick cursor

A little sidekick that follows your cursor around. (´ ・ω・`)ノ╰(・ิω・ิ )

TIP

This component is designed for mouse input. It is recommended to use a computer or a device with a mouse.

Usage Examples

Basic Usage

Normally follows the cursor, with special interactions when touching specific elements.

I am a Title
I am a paragraph (try selecting this text)

Editable div


Codlin's Fish TankGreedy Codfish
View example source code
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
    <cursor-sidekick v-if="enable" />

    <base-checkbox
      v-model="enable"
      :label="t('enableSidekick')"
      class="border rounded p-4"
    />

    <div class="flex flex-col gap-2">
      <div class="text-2xl font-bold">
        {{ t('title') }}
      </div>

      <div class="">
        {{ t('selectText') }}
      </div>

      <hr>

      <base-input
        v-model="text"
        :label="t('textInput')"
      />
      <div class="h-[20vh] w-1/2 border rounded">
        <textarea
          v-model="text"
          :placeholder="t('multilineText')"
          class="h-full w-full p-2"
        />
      </div>

      <div
        contenteditable
        class="w-2/3 border rounded p-2"
      >
        {{ t('editableDiv') }}
      </div>

      <hr>

      <base-btn :label="t('button')" />
      <base-btn
        disabled
        :label="t('naughtyBtn')"
      />

      <hr>

      <a
        href="https://codlin.me/"
        target="_blank"
      >
        {{ t('codlinBlog') }}
      </a>

      <img
        src="/low/painting-codfish-bakery.webp"
        :alt="t('greedyCodfish')"
      >
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseCheckbox from '../../base-checkbox.vue'
import BaseInput from '../../base-input.vue'
import CursorSidekick from '../cursor-sidekick.vue'

const enable = ref(false)
const text = ref('')

const { t } = useI18n()
</script>

Custom Content

Design your own Provider to create all sorts of quirky interactions! ლ(´∀`ლ)

Try selecting parts of this text that contain Codfish and parts that don't, and see the difference.


View example source code
vue
<template>
  <div class="w-full flex flex-col gap-4 border border-gray-200 rounded-xl p-6">
    <cursor-sidekick
      v-if="enable"
      color="#35abf0"
      :hover-providers="hoverProviderList"
      :select-providers="selectProviderList"
    />

    <base-checkbox
      v-model="enable"
      :label="t('enableSidekick')"
      class="border rounded p-4"
    />

    <div class="flex flex-col gap-2">
      <div class="">
        {{ t('selectHint') }}
      </div>

      <hr>

      <base-btn
        disabled
        :label="t('naughtyBtn')"
      />

      <hr>

      <img
        src="/low/photography-ears-of-rice.webp"
        url="https://www.flickr.com/photos/coodfish/albums/"
      >
    </div>
  </div>
</template>

<script setup lang="ts">
import type { ContentProvider } from '../use-content-provider'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseBtn from '../../base-btn.vue'
import BaseCheckbox from '../../base-checkbox.vue'
import CursorSidekick from '../cursor-sidekick.vue'

const enable = ref(false)

const { t } = useI18n()

const hoverProviderList = computed<ContentProvider[]>(() => [
  {
    match(data) {
      if ('rect' in data)
        return false

      if (
        !(data instanceof HTMLButtonElement)
        && data?.getAttribute('role') !== 'button'
      ) {
        return false
      }

      return data.innerHTML.includes('色色') || data.innerHTML.includes('naughty')
    },
    getContent: () => ({
      btnList: [
        {
          label: t('naughtyPortal'),
          onClick() {
            window.open(
              'https://raw.githubusercontent.com/tpai/dogedeck/main/cards/%E6%8A%97%E8%89%B2%E8%89%B2%E8%97%A5.png',
              '_blank',
            )
          },
        },
      ],
    }),
  },

  {
    match(data) {
      if ('rect' in data)
        return false

      if (data instanceof HTMLImageElement) {
        return true
      }

      return false
    },
    getContent(param) {
      const { element } = param
      const target = element?.value

      if (!(target instanceof HTMLImageElement))
        return

      const url = target.getAttribute('url')
      if (!url)
        return

      return {
        btnList: [
          {
            label: t('viewMorePhotos'),
            onClick() {
              window.open(url ?? '', '_blank')
            },
          },
        ],
      }
    },
  },
])

const selectProviderList = computed<ContentProvider[]>(() => [
  {
    match(data) {
      if (!('rect' in data))
        return false

      return data.text.includes('鱈魚') || data.text.includes('Codfish')
    },
    getContent: () => ({
      text: `${t('foundYouLine1')}<br>${t('foundYouLine2')}`,
      class: ' text-nowrap ',
      btnList: [
        {
          label: '🎬 Youtube',
          onClick() {
            window.open('https://www.youtube.com/@codfish2140', '_blank')
          },
        },
        {
          label: '💡 CodePen',
          onClick() {
            window.open('https://codepen.io/Codfish2140', '_blank')
          },
        },
        {
          label: t('codlinBlog'),
          onClick() {
            window.open('https://codlin.me/', '_blank')
          },
        },
      ],
    }),
  },
])
</script>

How It Works

This little useless component probably uses the most browser APIs of any component. Here are the APIs and their applications:

The sidekick's morphing animation is powered by the trusty anime.js.

Tooltip positioning uses Floating UI, which is extremely powerful and highly recommended.

Source Code

API

Props

interface Props {
  /** 單位 px */
  size?: number;
  /** \# 前綴之 HEX 格式
   * @default '#515151'
   */
  color?: string;
  /** 最大速度。越慢小跟班越悠哉。單位 px/ms
   * @default 1
   */
  maxVelocity?: number;
  /**  @default 100 */
  zIndex?: number;

  /** 匹配 active element 的 provider。
   *
   * 通常用於可點擊或 focus 的元素。
   */
  activeProviders?: ContentProvider[];

  /** 匹配 hover element 的 provider
   *
   * 只要 hover 到符合條件的元素,即會觸發。
   */
  hoverProviders?: ContentProvider[];

  /** 匹配選取文字的 provider  */
  selectProviders?: ContentProvider[];
}

ContentProvider definition:

interface BtnOption {
  label: string;
  onClick: (event?: Event) => void;
}

interface Content {
  /** 用於調整內容樣式 */
  class?: string;
  text?: string;
  /** 按鈕清單 */
  btnList?: BtnOption[];
  /** 預覽連結內容 */
  preview?: {
    src: string;
    class: string;
  };
}

export interface ContentProvider {
  /** 判斷目前元素或文字是否符合 */
  match: (
    data: HTMLElement | SelectionState
  ) => boolean;
  /** 取得小跟班顯示用內容 */
  getContent: (
    param: TargetParam
  ) => Content | undefined;
}

v0.60.0