Skip to content

Instantly share code, notes, and snippets.

@nathanlesage
Created March 7, 2025 16:32
Show Gist options
  • Select an option

  • Save nathanlesage/7c56502a1ebb23560d0a861397a5a518 to your computer and use it in GitHub Desktop.

Select an option

Save nathanlesage/7c56502a1ebb23560d0a861397a5a518 to your computer and use it in GitHub Desktop.
RingIndicator Vue Component
<template>
<!--
RingIndicator.vue Component
Copyright (2025) by Hendrik Erz.
This component is made available under the terms of the AGPL v3 only license.
You are free to use, share, and adapt this component, but you must make any
changes available under a compatible license.
This component has been open-sourced as a companion to a guide article,
published at https://www.hendrik-erz.de/post/guide-programmatically-draw-segmented-circles-or-ring-indicators-with-svg
-->
<svg
class="progress-ring"
v-bind:width="circleSize"
v-bind:height="circleSize"
v-bind:view-box="`0 0 ${circleSize} ${circleSize}`"
>
<g v-for="ring, ringIdx in props.rings">
<g>
<title v-if="ring.label">{{ ring.label }}</title>
<path
:d="arcForCircle(ringIdx)"
v-bind:stroke="ringColor(ringIdx)"
fill="transparent"
v-bind:stroke-width="lineWidth"
/>
</g>
<g v-for="segment, idx in ring.segments">
<title>{{ segment.label }}</title>
<path
:d="arcForSegment(ringIdx, idx)"
v-bind:stroke="segment.color"
fill="transparent"
v-bind:stroke-width="lineWidth"
/>
</g>
</g>
</svg>
</template>
<script setup lang="ts">
import { computed } from 'vue'
export interface RingSegment {
ratio: number
color: string
label?: string
}
export interface RingData {
segments: RingSegment[]
label?: string
baseRingColor?: string
}
const MAX_RAD = 2 * Math.PI
const props = defineProps<{
rings: RingData[]
/**
* How large should the SVG be?
*/
circleSize: number
/**
* How large should the lines of the rings be (default: 10% of the circle size)?
*/
lineWidth?: number,
/**
* The gap at the bottom of the ring(s), in percent of the circle.
*/
gap?: number
}>()
const circleGap = computed(() => Math.min(props.gap ?? 0.1, 1) * MAX_RAD)
const circleSize = computed(() => props.circleSize ?? 20)
const lineWidth = computed(() => props.lineWidth ?? (circleSize.value * 0.1))
function ringColor (ringIdx: number) {
return props.rings[ringIdx].baseRingColor ?? '#cccccc'
}
function circleRadius (ringIdx: number) {
const totalAvailableSpace = circleSize.value / 2
const ringCount = props.rings.length
const spacePerRing = totalAvailableSpace / ringCount
const radiusForRing = totalAvailableSpace - (ringIdx * spacePerRing) - (lineWidth.value / 2)
return radiusForRing
}
function segmentOffset (ringIdx: number, idx: number): number {
const segments = props.rings[ringIdx].segments
const prevRatios = segments.slice(0, idx).reduce((prev, cur) => prev + cur.ratio, 0)
const availableTheta = MAX_RAD - circleGap.value
const prevPathTheta = prevRatios * availableTheta
return prevPathTheta
}
function arcForSegment (ringIdx: number, idx: number): string {
const ratio = props.rings[ringIdx].segments[idx].ratio
const r = circleRadius(ringIdx)
const thetaOffset = segmentOffset(ringIdx, idx)
const startOffset = circleGap.value/2
const rot = 0
const availableTheta = MAX_RAD - circleGap.value
const offset = startOffset + thetaOffset
const theta0 = MAX_RAD * 0.75 - offset
const theta1 = theta0 - availableTheta * ratio
const translate = props.circleSize / 2
const x1 = Math.cos(theta0) * r + translate
const y1 = -Math.sin(theta0) * r + translate
const x2 = Math.cos(theta1) * r + translate
const y2 = -Math.sin(theta1) * r + translate
const largeArc = theta0 - theta1 > Math.PI ? 1 : 0
return `M ${x1} ${y1} A ${r} ${r} ${rot} ${largeArc} 1 ${x2} ${y2}`
}
function arcForCircle (ringIdx: number): string {
const r = circleRadius(ringIdx)
const startOffset = circleGap.value / 2
const availableTheta = MAX_RAD - circleGap.value
const offset = startOffset
const theta0 = MAX_RAD * 0.75 - offset
const theta1 = theta0 - availableTheta
const translate = props.circleSize / 2
const x1 = Math.cos(theta0) * r + translate
const y1 = -Math.sin(theta0) * r + translate
const x2 = Math.cos(theta1) * r + translate
const y2 = -Math.sin(theta1) * r + translate
return `M ${x1} ${y1} A ${r} ${r} 0 1 1 ${x2} ${y2}`
}
</script>
<style lang="css" scoped>
svg path {
filter: saturate(50%);
transition: 0.5s filter ease;
}
svg:hover {
path:not(:hover) {
filter: saturate(50%);
}
path:hover {
filter: saturate(100%);
}
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment