const menu = RM.menu([
RM.circle(50, "Circle", {fill: "papayawhip"}),
]);
const { el } = RM.build(menu);
Documentation
Structure of a ring menu
Circles
Menus are assembled from the inside out. Let's take a look at the simplest menu possible; a menu that only contains one circle.
Circles are optional and can only be found in the very center of a menu. Here we've provided the circle's radius, content and the attributes we want on the circle.
Text
If you'd like to specify attributes on the text content itself, wrap it in a RM.text
:
const menu = RM.menu([
RM.circle(50, RM.text("Circle", {fill: "blue"}), {fill: "papayawhip"}),
]);
const { el } = RM.build(menu);
Rings and sectors
Menus are mostly made out of rings. A ring is a collection of sectors which are all on the same distance from the center of the menu. A sector is an SVG <path>
element. Sectors are rendered clockwise starting from the top, in the order they're provided to the ring.
const menu = RM.menu([
RM.circle(50, "Circle", {fill: "papayawhip"}),
RM.ring(100, [
RM.sector("Sector 1", {fill: "lightblue"}),
RM.sector("Sector 2", {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
Here we specified a ring with a width of 100 pixels that consists of two sectors. Notice how we didn't need to specify the size of our sectors; they split the ring into equal parts by themselves. What if we wanted our ring to have six sectors instead? Also, since it's already been mentioned that circles are optional, let's make this menu not have one:
const menu = RM.menu([
RM.ring(150, [
RM.sector("Sector 1", {fill: "lightblue"}),
RM.sector("Sector 2", {fill: "lightgreen"}),
RM.sector("Sector 3", {fill: "lightblue"}),
RM.sector("Sector 4", {fill: "lightgreen"}),
RM.sector("Sector 5", {fill: "lightblue"}),
RM.sector("Sector 6", {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
As you might have expected, the six sectors divide the ring equally. Also, because menus are always built from the inside out and we didn't use a circle in this menu, it means that the ring will start from the very center of the menu, resulting in a menu that looks kind of like a pie chart.
The great thing about menus is that they can have an arbitrary amount of rings, which are all independent one from another. Let's say we wanted a menu that has one ring with three sectors inside, with another ring with two sectors around the first ring. We can construct that menu like so:
const menu = RM.menu([
RM.ring(100, [
RM.sector("Inner 1", {fill: "lightblue"}),
RM.sector("Inner 2", {fill: "lightgreen"}),
RM.sector("Inner 3", {fill: "lightsalmon"}),
]),
RM.ring(100, [
RM.sector("Outer 1", {fill: "lightpink"}),
RM.sector("Outer 2", {fill: "yellowgreen"}),
]),
]);
const { el } = RM.build(menu);
Gaps
What if we wanted to include some space between the two rings in the previous example? We can do that by using a gap. A gap is simply a ring of empty space. A menu can have an arbitrary amount of gaps.
const menu = RM.menu([
RM.gap(40),
RM.ring(100, [
RM.sector("Inner 1", {fill: "lightblue"}),
RM.sector("Inner 2", {fill: "lightgreen"}),
RM.sector("Inner 3", {fill: "lightsalmon"}),
]),
RM.gap(50),
RM.ring(100, [
RM.sector("Outer 1", {fill: "lightpink"}),
RM.sector("Outer 2", {fill: "yellowgreen"}),
]),
]);
const { el } = RM.build(menu);
Here we've added a 50 pixel wide gap between the two rings. Notice how we've also added a 40 pixel wide gap before the first ring. If you don't need a circle in the center of your menu, but you also don't want your first ring to start from the center, then use a gap.
Angles and offsets
Static angles
While having sectors divide the ring equally by default might be useful in some cases, sometimes you'll want to specify the angle of each sector yourself. You can do that like so:
const menu = RM.menu([
RM.gap(40),
RM.ring(100, [
RM.sector("30", 30, {fill: "lightblue"}),
RM.sector("40", 40, {fill: "lightgreen"}),
RM.sector("50", 50, {fill: "lightsalmon"}),
]),
]);
const { el } = RM.build(menu);
Here we've constructed sectors of angles of 30, 40 and 50 degrees. The sum of the sector angles doesn't have to be equal to 360 degrees for them to render, but it must not be larger than 360.
Static offsets
What if we wanted to have empty space between sectors? We can do by specifying an offset on a sector:
const menu = RM.menu([
RM.gap(40),
RM.ring(100, [
RM.sector("1", 30, 20, {fill: "lightblue"}),
RM.sector("2", 40, 50, {fill: "lightgreen"}),
RM.sector("3", 50, {fill: "lightsalmon"}),
]),
]);
const { el } = RM.build(menu);
In this example we have two offsets, one of 20 degrees and the other one of 50 degrees; resulting in a 20 degree gap between sectors 1 and 2, as well as a 50 degree gap between sectors 2 and 3.
Optionally, we can set a global offset on the ring. If a sector doesn't have its offset specified, it will use the global offset of its parent ring (which defaults to zero if unspecified).
const menu = RM.menu([
RM.gap(40),
RM.ring(100, 20, [
RM.sector("1", 30, {fill: "lightblue"}),
RM.sector("2", 40, 100, {fill: "lightgreen"}),
RM.sector("3", 50, {fill: "lightsalmon"}),
]),
]);
const { el } = RM.build(menu);
Here, the offsets of sectors 1 and 3 are unspecified, so they inherit the ring's global offset of 20 degrees, while sector 2 uses its own offset of 100 degrees.
If we want to offset the entire ring by some amount, we can define the offset of the ring right after the global offset.
const menu = RM.menu([
RM.ring(100, 0, 45, [
RM.sector("1", {fill: "lightblue"}),
RM.sector("2", {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
Dynamic values
Dynamic angles and offsets
Instead of supplying an angle or an offset as a static value, ring-menu
also has the concept of dynamic values. A dynamic value has a factor and represents a part of the whole. The higher the factor of a dynamic value, the bigger part of the whole that particular dynamic value will take. Let's take a look at an example:
const d = RM.dynamic;
const menu = RM.menu([
RM.ring(100, [
RM.sector("Sector 1", d(1), {fill: "lightblue"}),
RM.sector("Sector 2", d(1), {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
The resulting ring might look familiar; in the beginning of this page, we created a ring with sectors that didn't have defined angles:
const d = RM.dynamic;
const menu = RM.menu([
RM.ring(100, [
RM.sector("Sector 1", {fill: "lightblue"}),
RM.sector("Sector 2", {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
If you don't specify the angle of a sector, it will default to a dynamic value of factor 1. If all sectors in a ring have the same dynamic factor, they will all have equal angles.
What if we wanted one sector to be twice as large as the other? To do that, we could calculate their angles manually and set them as static values. An easier way would be to make use of dynamic values.
const d = RM.dynamic;
const menu = RM.menu([
RM.ring(100, [
RM.sector("Sector 1", d(2), {fill: "lightblue"}),
RM.sector("Sector 2", d(1), {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
Sector offsets can be dynamic as well.
const d = RM.dynamic;
const menu = RM.menu([
RM.ring(150, [
RM.sector("Sector 1", d(2), d(1), {fill: "lightblue"}),
RM.sector("Sector 2", d(1), d(2), {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
Sector 1 is twice as big as sector 2, while the space from sector 1 to sector 2 is twice as small as the space from sector 2 to sector 1. Sectors or offsets of the same dynamic factor will have the same angle.
For convenience, ring-menu
also provides a dsector
shortcut for creating a sector where the provided angle and offset are dynamic values by default. You can't provide static values to a dsector
. Here's the same example as the previous one, this time using dsector
:
const d = RM.dynamic;
const menu = RM.menu([
RM.ring(150, [
RM.dsector("Sector 1", 2, 1, {fill: "lightblue"}),
RM.dsector("Sector 2", 1, 2, {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
A ring offset can also be a dynamic value.
const d = RM.dynamic;
const menu = RM.menu([
RM.ring(150, 0, d(0.5), [
RM.sector("Sector 1", {fill: "lightblue"}),
RM.sector("Sector 2", {fill: "lightgreen"}),
RM.sector("Sector 3", {fill: "lightblue"}),
RM.sector("Sector 4", {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
Mixing dynamic and static values
Let's say you wanted a ring of sectors with fixed angles, but you also wanted to make sure that the space between each sector is equal. It's easy with dynamic values:
const d = RM.dynamic;
const menu = RM.menu([
RM.gap(50),
RM.ring(100, d(1), [
RM.sector("40", 40, {fill: "lightblue"}),
RM.sector("80", 80, {fill: "lightgreen"}),
RM.sector("20", 20, {fill: "lightblue"}),
RM.sector("100", 100, {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
Remember that dynamic values represent parts of a whole. This whole is the entire 360 degrees minus any static angles or offsets your sectors might have. If you were to calculate the offsets manually, you'd first have to calculate the whole and divide it by the number of sectors. Using dynamic values does all of that for you under the hood.
We could just as easily do the opposite; make a ring where the offsets between sectors are static while the sectors themselves are dynamic:
const d = RM.dynamic;
const menu = RM.menu([
RM.gap(50),
RM.ring(100, 30, [
RM.sector("1", {fill: "lightblue"}),
RM.sector("2", {fill: "lightgreen"}),
RM.sector("3", d(2), {fill: "lightblue"}),
RM.sector("4", {fill: "lightgreen"}),
]),
]);
const { el } = RM.build(menu);
We've ensured that the space between each sector is exactly 30 degrees, while also making sector 3 twice as large as the others.
Interactivity
Refs
Sometimes you might want to interact with a part of the menu, like for example to add an event listener. Instead of having to add a class to the part and then query the DOM for it, you may use the special ref
attribute like so:
const menu = RM.menu([
RM.gap(50),
RM.ring(75, [
RM.sector("Rotate", {fill: "lightgreen", ref: "rotate"}),
RM.sector("Reset", {fill: "lightblue", ref: "reset"}),
], {ref: "ring"}),
]);
let angle = 0;
const {el, refs} = RM.build(menu);
refs.rotate.wrapper.addEventListener("click", () => {
angle += 15;
refs.ring.wrapper.style = `transform: rotate(${angle}deg)`;
});
refs.reset.wrapper.addEventListener("click", () => {
angle = 0;
refs.ring.wrapper.style = `transform: rotate(${angle}deg)`;
});
Along with the resulting SVG in the el
property, you also get a refs
, which is an object whose keys are the ref
strings you've specified, and the values are the SVG elements themselves.
Note that each ref actually has two properties: wrapper
and self
. Let's say we had a circle. We want the number inside the circle to increase whenever the circle is clicked. We would do that like so:
const menu = RM.menu([
RM.circle(100, RM.text("0", {ref: "count"}), {ref: "click", fill: "lightgreen"}),
]);
const {el, refs} = RM.build(menu);
let count = 0;
refs.click.wrapper.addEventListener("click", () => {
count += 1;
refs.count.self.textContent = String(count);
})
Here's what the SVG representation of what the RM.circle
looks like:
<g>
<circle></circle>
<text>0</text>
</g>
Notice that the <circle>
and the <text>
elements aren't parent and child. Nesting the elements in such a way doesn't make sense in SVG. Instead, a <g>
element is used to group them into a logical whole, making them siblings.
The following is true:
refs.click.wrapper
is the<g>
element wrapping the<circle>
and the<text>
.refs.click.self
is the<circle>
element.
Since we want to increment the counter when the circle is clicked, we'll add the handler on the wrapper
. If we would add it on self
, the handler would get attached to the <circle>
, which means clicking on the <text>
inside of the circle would do nothing, resulting in a frustrating user experience.
As for updating the counter, it doesn't matter whether we update refs.count.self
or the refs.count.wrapper
, as they both point to the same <text>
element.
As a general rule of thumb:
- Use
wrapper
when you're handling the menu part as a logical whole. - Use
self
when you want to perform a specific action on the part itself instead of the wrapper, such as dynamically applying a style.
Here's a table showing what elements wrapper
and self
would point to when the ref
attribute is present on a certain menu part. Also note that in the following table, circle
, sector
and dsector
are all shown having some content in them. If any of those elements is created without any content, there's no need for a <g>
wrapper, so the wrapper
property will point to the same element as self
does.
Part | SVG representation | wrapper | self |
---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Examples
Color wheel
This example demonstrates a simple color wheel where clicking on a sector displays that sector's color in the center circle, as well as colors the ring around the circle in the selected color. Since includeTabIndexes
is set to true
in the menu options, the menu can also be traversed by using the Tab key. Additional code is added below so that the currently focused sector can be selected by pressing Enter.
const colors1 = ["#FF5858", "#FFA458", "#FFC758", "#FFE258", "#FFFF58", "#BAEE52", "#46CC46", "#359999", "#4765AB", "#624AB0", "#8541AB", "#CD4793"];
const colors2 = ["#FF5858", "#FF8858", "#FFA458", "#FFB858", "#FFC758", "#FFD558", "#FFE258", "#FFF058", "#FFFF58", "#DBF655", "#BAEE52", "#92E34E", "#46CC46", "#3CAF7E", "#359999", "#4079A4", "#4765AB", "#4F4FB3", "#624AB0", "#7246AE", "#8641AB", "#A739A7", "#CD4693", "#E44E7B"];
const colorSector = color => RM.sector({fill: color, class: "color hoverable"})
const menu = RM.menu([
RM.circle(50, RM.text("", {fill: "white", ref: "selectedColor"})),
RM.gap(25),
RM.ring(20, [
RM.sector({class: "no-stroke", ref: "colorRing"}),
]),
RM.gap(25),
RM.ring(100, colors1.map(colorSector)),
RM.gap(25),
RM.ring(100, 0, RM.dynamic(0.5), colors2.map(colorSector)),
]);
const {el, refs} = RM.build(menu, {includeTabIndexes: true});
function updateSelectedColor(color) {
refs.colorRing.self.setAttribute("fill", color);
refs.selectedColor.self.textContent = color;
}
// Make it so that sectors can be selected using Tab + Enter as well
el.addEventListener("keypress", e => {
if (e.key === "Enter" && el.contains(document.activeElement)) {
document.activeElement.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancellable: true,
}));
}
})
el.addEventListener("click", e => {
if (e.target.matches(".color")) {
const newColor = e.target.getAttribute("fill");
updateSelectedColor(newColor);
}
});
API
function dynamic(factor: number): Dynamic;
function text(content: string, attrs?: object): Text;
function circle(radius: number): Circle;
function circle(radius: number, attrs?: object): Circle;
function circle(radius: number, content?: Content, attrs?: object): Circle;
function gap(width: number): Gap;
function sector(attrs?: object): Sector;
function sector(content: Content, attrs?: object): Sector;
function sector(angle: Angle, offset?: Angle, attrs?: object): Sector;
function sector(content?: Content, angle?: Angle, attrs?: object): Sector;
function sector(content?: Content, angle?: Angle, offset?: Angle, attrs?: object): Sector;
function dsector(attrs?: object): Sector;
function dsector(content: Content, attrs?: object): Sector;
function dsector(angleFactor: number, offsetFactor?: number, attrs?: object): Sector;
function dsector(content?: Content, angleFactor?: number, attrs?: object): Sector;
function dsector(content?: Content, angleFactor?: number, offsetFactor?: number, attrs?: object): Sector;
function ring(width: number, sectors: Sectors, attrs?: object): Ring;
function ring(width: number, globalOfset: Angle, sectors: Sectors, attrs?: object): Ring;
function ring(width: number, globalOfset: Angle, offset: Angle, sectors: Sectors, attrs?: object): Ring;
function menu(structure: NonEmptyArray<Circle | Gap | Ring>, attrs?: object): Menu;