A meaningful part of our work involves building in Articulate Storyline, and a recurring component of that work is creating flipcards. At 50 courses a year with 5 modules each, flipcards appearing in even 2 of those modules means creating **100 flipcards annually**.
I built a generator to automate flipcard creation that:
- **Reduced design time** from 0.5–5 hours to 2–5 minutes
- **Eliminated the need for an Articulate license** to create flipcards
- **Baked accessibility in** by default
### The Problem
#### It takes a lot of time
The time it takes to create flipcards is high, especially early on. It might take you 5 hours the very first time. With practice, that drops to 1–2 hours, and eventually to about 30 minutes once you're working from templates. But even at 30 minutes, 100 flipcards a year is still 50 hours, and that's before accounting for QA and accessibility review.
#### It's a low-variance task that stops teaching you anything
The first time you create a flipcard, you learn a lot about Storyline. You even learn a little the second time. But by the tenth time? You're just doing the chore. Layouts are predictable. There are only so many ways to arrange cards on a screen and have them look good, and the content inside the cards is often finalized before you even open the software. Once you've learned the task, every subsequent repetition is a drain on time and attention that could go elsewhere.
#### Not everyone can do it
Storyline licenses cost thousands of dollars per seat. If you don't have access to Storyline, you ask someone who does to create flipcards for you. This introduces wait time and pulls those people away from higher-judgment work.
### Design Decisions
#### Reducing the chore
The generator automates the creation of a template version of the flipcards. But designers who want to learn Storyline or create something outside the standard template can still build a flipcard from scratch in Storyline. The generator just means no one is _forced_ into the chore when the output is predictable and the content is already finalized.
#### Improving accessibility inside and out
"Accessibility" means two different things here.
1. **Internal accessibility**: "Who can do the work" expanded significantly. Creating flipcards no longer requires an Articulate license or deep Storyline expertise. Any ID can now produce a polished, template flipcard in 2–5 minutes and direct their remaining time toward work that actually requires creative judgment.
2. **External accessibility**: "The experience for end users" improved as well. Flipcard creation in Storyline has well-known friction points: the flip animation is finicky to get right, clicking a hyperlink inside a card can accidentally trigger the flip, and alt-text requires deliberate effort to surface. The generator handles all of this correctly by default. Animations are smooth, links work as expected, and alt-text editing is surfaced to the user while everything else is managed automatically behind the scenes.
#### Eliminating turnaround time
The old workflow had a gap baked into it:
<svg viewBox="0 0 960 140" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flipcard process animation">
<style>
:root { --page-bg: #fff; }
.bg { fill: var(--page-bg); }
/* Fallback to system theme if no parent-theme detection */
@media (prefers-color-scheme: dark) {
:root { --page-bg: #000; }
.tBlack { fill: #fff; }
.arrow { stroke: #fff; }
.hint { fill: rgba(255,255,255,.65); }
}
/* Default (light) */
.tBlack{
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
font-weight: 700;
fill:#000;
font-size:16px;
}
.tRed{
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
font-weight:400;
fill:#8C1D40;
font-size:14px;
}
.arrow { stroke:#000; stroke-width:2.5; fill:none; stroke-linecap:round; stroke-linejoin:round; }
.arrowG{
transform-box: fill-box;
transform-origin: left center;
}
.hint { font:600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; fill:rgba(0,0,0,.55); }
/* make whole svg clickable */
svg { cursor: pointer; user-select: none; -webkit-user-select: none; }
</style>
<rect class="bg" width="960" height="140" />
<!-- box y=13, h=99 => centerY = 62.5 -->
<!-- BLACK text centered via translate(cx,62.5); RED text uses absolute y=92 -->
<g id="before">
<g id="b1">
<rect x="14" y="13" width="120" height="99" rx="20" fill="#F2F9E9"/>
<g transform="translate(74 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Identify</tspan>
<tspan x="0" dy="20">need</tspan>
</text>
</g>
</g>
<g id="b2">
<rect x="176" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(236 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Submit</tspan>
<tspan x="0" dy="20">request</tspan>
</text>
</g>
<text class="tRed" x="236" y="92" text-anchor="middle" dominant-baseline="middle">(5 to 10 mins)</text>
</g>
<g id="b3">
<rect x="338" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(398 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">Wait</text>
</g>
<text class="tRed" x="398" y="92" text-anchor="middle" dominant-baseline="middle">(days to months)</text>
</g>
<g id="b4">
<rect x="500" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(560 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Flipcard</tspan>
<tspan x="0" dy="20">created</tspan>
</text>
</g>
<text class="tRed" x="560" y="92" text-anchor="middle" dominant-baseline="middle">(30 mins to 5 hrs)</text>
</g>
<g id="b5">
<rect x="661" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(721 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Quality</tspan>
<tspan x="0" dy="20">assurance</tspan>
</text>
</g>
<text class="tRed" x="721" y="92" text-anchor="middle" dominant-baseline="middle">(30 mins to 1 hr)</text>
</g>
<g id="b6">
<rect x="824" y="13" width="120" height="99" rx="20" fill="#78BE20"/>
<g transform="translate(884 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Upload</tspan>
<tspan x="0" dy="20">and embed</tspan>
</text>
</g>
</g>
<!-- BEFORE arrows: middle arrows disappear; outer arrows move with outer boxes -->
<g id="ar_b1" class="arrowG"><path class="arrow" d="M146 62.5 H168 M168 62.5 L162 56 M168 62.5 L162 69"/></g>
<g id="ar_b2" class="arrowG"><path class="arrow" d="M308 62.5 H330 M330 62.5 L324 56 M330 62.5 L324 69"/></g>
<g id="ar_b3" class="arrowG"><path class="arrow" d="M470 62.5 H492 M492 62.5 L486 56 M492 62.5 L486 69"/></g>
<g id="ar_b4" class="arrowG"><path class="arrow" d="M632 62.5 H653 M653 62.5 L647 56 M653 62.5 L647 69"/></g>
<g id="ar_b5" class="arrowG"><path class="arrow" d="M793 62.5 H816 M816 62.5 L810 56 M816 62.5 L810 69"/></g>
</g>
<!-- AFTER-only additions -->
<g id="afterAdd" opacity="0">
<g id="af2">
<rect x="420" y="13" width="120" height="99" rx="20" fill="#C9E5A6"/>
<g transform="translate(480 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Create</tspan>
<tspan x="0" dy="20">flipcard</tspan>
</text>
</g>
<text class="tRed" x="480" y="92" text-anchor="middle" dominant-baseline="middle">(5 mins)</text>
</g>
</g>
<text x="480" y="132" text-anchor="middle" class="hint">Click to play • Click again to reverse</text>
<script><![CDATA[
(() => {
const svg = document.documentElement;
// Theme-aware background (Obsidian Publish light/dark)
function applyResponsiveBackground() {
let bg = null;
// 1) Try to follow Obsidian's theme class in the parent document (works when same-origin)
try {
const pb = window.parent && window.parent.document && window.parent.document.body;
if (pb && pb.classList) {
const cls = pb.classList;
const isDark =
cls.contains("theme-dark") ||
cls.contains("mod-dark") ||
cls.contains("is-dark") ||
cls.contains("dark") ||
cls.contains("obsidian-dark");
const isLight =
cls.contains("theme-light") ||
cls.contains("mod-light") ||
cls.contains("is-light") ||
cls.contains("light") ||
cls.contains("obsidian-light");
if (isDark) bg = "#000";
else if (isLight) bg = "#fff";
}
} catch (e) {
// ignore cross-origin access
}
// 2) Fallback: system theme
if (!bg) {
bg = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "#000" : "#fff";
}
// Set CSS var on the SVG root. (Used by .bg fill.)
svg.style.setProperty("--page-bg", bg);
// Optional: if we're forcing a dark bg, switch black text/arrows to white for legibility.
// (The CSS @media dark already does this for system theme; this handles Obsidian manual toggle.)
const isForcedDark = bg === "#000";
svg.classList.toggle("force-dark", isForcedDark);
if (isForcedDark) {
svg.querySelectorAll(".tBlack").forEach(n => n.setAttribute("fill", "#fff"));
svg.querySelectorAll(".arrow").forEach(n => n.setAttribute("stroke", "#fff"));
const hint = svg.querySelector(".hint");
if (hint) hint.setAttribute("fill", "rgba(255,255,255,.65)");
} else {
svg.querySelectorAll(".tBlack").forEach(n => n.setAttribute("fill", "#000"));
svg.querySelectorAll(".arrow").forEach(n => n.setAttribute("stroke", "#000"));
const hint = svg.querySelector(".hint");
if (hint) hint.setAttribute("fill", "rgba(0,0,0,.55)");
}
}
applyResponsiveBackground();
// Watch for changes (Obsidian can toggle theme without reload)
try {
const pb = window.parent && window.parent.document && window.parent.document.body;
if (pb) {
const mo = new MutationObserver(() => applyResponsiveBackground());
mo.observe(pb, { attributes: true, attributeFilter: ["class"] });
}
} catch (e) {}
if (window.matchMedia) {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
if (mq.addEventListener) mq.addEventListener("change", applyResponsiveBackground);
else if (mq.addListener) mq.addListener(applyResponsiveBackground);
}
const prefersReduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const el = (id) => document.getElementById(id);
const b1 = el("b1");
const b6 = el("b6");
const mids = [el("b2"), el("b3"), el("b4"), el("b5")];
// BEFORE arrows
const ar1 = el("ar_b1");
const ar2 = el("ar_b2");
const ar3 = el("ar_b3");
const ar4 = el("ar_b4");
const ar5 = el("ar_b5");
// AFTER-only merged middle (box only — no new arrows)
const afterAdd = el("afterAdd");
const D = prefersReduced ? 1 : 950;
const ease = "cubic-bezier(.2,.8,.2,1)";
// Keep handles to every animation so we can reverse on the next click
let anims = [];
function setBaseBefore() {
// Clear inline styles so the "before" state is truly clean
[b1, b6, ...mids, ar1, ar2, ar3, ar4, ar5, afterAdd].forEach(n => {
if (!n) return;
n.style.transform = "";
n.style.opacity = "";
n.style.filter = "";
n.style.pointerEvents = "";
});
afterAdd.style.opacity = "0";
afterAdd.style.pointerEvents = "none";
}
function cancelAll() {
anims.forEach(a => { try { a.cancel(); } catch(e) {} });
anims = [];
}
function playForward() {
cancelAll();
setBaseBefore();
// Target positions (horizontal only)
const b1dx = 244; // 14 -> 258
const b6dx = -242; // 824 -> 582
// Boxes slide in
anims.push(
b1.animate(
[{ transform: "translateX(0px)" }, { transform: `translateX(${b1dx}px)` }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
b6.animate(
[{ transform: "translateX(0px)" }, { transform: `translateX(${b6dx}px)` }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
// Outer arrows move with the outer rectangles and remain visible
anims.push(
ar1.animate(
[{ transform: "translateX(0px)", opacity: 1 }, { transform: `translateX(${b1dx}px)`, opacity: 1 }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
ar5.animate(
[{ transform: "translateX(0px)", opacity: 1 }, { transform: `translateX(${b6dx}px)`, opacity: 1 }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
// Middle boxes converge toward the merged box and fade out (subtle morph blur)
const starts = [176, 338, 500, 661];
mids.forEach((g, i) => {
const dx = 420 - starts[i];
anims.push(
g.animate(
[
{ transform: "translateX(0px) scale(1)", opacity: 1, filter: "blur(0px)" },
{ transform: `translateX(${dx}px) scale(0.92)`, opacity: 0, filter: "blur(1.6px)" }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
});
// Middle arrows (ONLY the three middle arrows) move+shrink and fade out
anims.push(
ar2.animate(
[
{ transform: "translateX(0px) scaleX(1)", opacity: 1 },
{ transform: `translateX(${420-176}px) scaleX(0.15)`, opacity: 0 }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
ar3.animate(
[
{ transform: "translateX(0px) scaleX(1)", opacity: 1 },
{ transform: `translateX(${420-338}px) scaleX(0.15)`, opacity: 0 }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
ar4.animate(
[
{ transform: "translateX(0px) scaleX(1)", opacity: 1 },
{ transform: `translateX(${420-500}px) scaleX(0.15)`, opacity: 0 }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
// Merged middle scales+deblurs in
afterAdd.style.pointerEvents = "none";
anims.push(
afterAdd.animate(
[
{ opacity: 0, transform: "scale(0.92)", filter: "blur(1.2px)" },
{ opacity: 1, transform: "scale(1)", filter: "blur(0px)" }
],
{ duration: D, delay: Math.max(0, D * 0.18), easing: ease, fill: "forwards" }
)
);
}
// Init (load in the BEFORE state; animation runs on click)
setBaseBefore();
// Toggle: reverse on every click (and forward again on the next click)
svg.addEventListener("click", () => {
if (!anims.length) {
playForward();
return;
}
anims.forEach(a => { try { a.reverse(); } catch(e) {} });
});
})();
]]></script>
</svg>
1. ID designs the course and identifies a need for a flipcard
2. Flipcard content is finalized
3. A request is submitted _(5–10 min)_
4. An Articulate license holder creates the flipcard _(1–5 hours)_
5. The ID QAs the design; someone else checks accessibility _(~30 min)_
6. Uploaded to AWS
**After:**
1. ID designs the course and identifies a need for a flipcard
2. Flipcard content is finalized
3. ID creates the flipcard themselves using the generator _(2–5 min)_
4. Uploaded to AWS
The request process is gone. The wait time — which could stretch significantly depending on how busy license holders were — is gone. QA is effectively eliminated because the ID creates it themselves and sees the output immediately.
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 960 140" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flipcard process animation">
<style>
:root { --page-bg: #fff; }
.bg { fill: var(--page-bg); }
/* Fallback to system theme if no parent-theme detection */
@media (prefers-color-scheme: dark) {
:root { --page-bg: #000; }
.tBlack { fill: #fff; }
.arrow { stroke: #fff; }
.hint { fill: rgba(255,255,255,.65); }
}
/* Default (light) */
.tBlack{
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
font-weight: 700;
fill:#000;
font-size:16px;
}
.tRed{
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
font-weight:400;
fill:#8C1D40;
font-size:14px;
}
.arrow { stroke:#000; stroke-width:2.5; fill:none; stroke-linecap:round; stroke-linejoin:round; }
.arrowG{
transform-box: fill-box;
transform-origin: left center;
}
.hint { font:600 12px/1 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; fill:rgba(0,0,0,.55); }
/* make whole svg clickable */
svg { cursor: pointer; user-select: none; -webkit-user-select: none; }
</style>
<rect class="bg" width="960" height="140" />
<!-- box y=13, h=99 => centerY = 62.5 -->
<!-- BLACK text centered via translate(cx,62.5); RED text uses absolute y=92 -->
<g id="before">
<g id="b1">
<rect x="14" y="13" width="120" height="99" rx="20" fill="#F2F9E9"/>
<g transform="translate(74 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Identify</tspan>
<tspan x="0" dy="20">need</tspan>
</text>
</g>
</g>
<g id="b2">
<rect x="176" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(236 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Submit</tspan>
<tspan x="0" dy="20">request</tspan>
</text>
</g>
<text class="tRed" x="236" y="92" text-anchor="middle" dominant-baseline="middle">(5 to 10 mins)</text>
</g>
<g id="b3">
<rect x="338" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(398 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">Wait</text>
</g>
<text class="tRed" x="398" y="92" text-anchor="middle" dominant-baseline="middle">(days to months)</text>
</g>
<g id="b4">
<rect x="500" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(560 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Flipcard</tspan>
<tspan x="0" dy="20">created</tspan>
</text>
</g>
<text class="tRed" x="560" y="92" text-anchor="middle" dominant-baseline="middle">(30 mins to 5 hrs)</text>
</g>
<g id="b5">
<rect x="661" y="13" width="120" height="99" rx="20" fill="#FFDCDC"/>
<g transform="translate(721 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Quality</tspan>
<tspan x="0" dy="20">assurance</tspan>
</text>
</g>
<text class="tRed" x="721" y="92" text-anchor="middle" dominant-baseline="middle">(30 mins to 1 hr)</text>
</g>
<g id="b6">
<rect x="824" y="13" width="120" height="99" rx="20" fill="#78BE20"/>
<g transform="translate(884 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Upload</tspan>
<tspan x="0" dy="20">and embed</tspan>
</text>
</g>
</g>
<!-- BEFORE arrows: middle arrows disappear; outer arrows move with outer boxes -->
<g id="ar_b1" class="arrowG"><path class="arrow" d="M146 62.5 H168 M168 62.5 L162 56 M168 62.5 L162 69"/></g>
<g id="ar_b2" class="arrowG"><path class="arrow" d="M308 62.5 H330 M330 62.5 L324 56 M330 62.5 L324 69"/></g>
<g id="ar_b3" class="arrowG"><path class="arrow" d="M470 62.5 H492 M492 62.5 L486 56 M492 62.5 L486 69"/></g>
<g id="ar_b4" class="arrowG"><path class="arrow" d="M632 62.5 H653 M653 62.5 L647 56 M653 62.5 L647 69"/></g>
<g id="ar_b5" class="arrowG"><path class="arrow" d="M793 62.5 H816 M816 62.5 L810 56 M816 62.5 L810 69"/></g>
</g>
<!-- AFTER-only additions -->
<g id="afterAdd" opacity="0">
<g id="af2">
<rect x="420" y="13" width="120" height="99" rx="20" fill="#C9E5A6"/>
<g transform="translate(480 62.5)">
<text class="tBlack" text-anchor="middle" dominant-baseline="middle" y="0">
<tspan x="0" dy="-10">Create</tspan>
<tspan x="0" dy="20">flipcard</tspan>
</text>
</g>
<text class="tRed" x="480" y="92" text-anchor="middle" dominant-baseline="middle">(5 mins)</text>
</g>
</g>
<text x="480" y="132" text-anchor="middle" class="hint">Click to play • Click again to reverse</text>
<script><![CDATA[
(() => {
const svg = document.documentElement;
// Theme-aware background (Obsidian Publish light/dark)
function applyResponsiveBackground() {
let bg = null;
// 1) Try to follow Obsidian's theme class in the parent document (works when same-origin)
try {
const pb = window.parent && window.parent.document && window.parent.document.body;
if (pb && pb.classList) {
const cls = pb.classList;
const isDark =
cls.contains("theme-dark") ||
cls.contains("mod-dark") ||
cls.contains("is-dark") ||
cls.contains("dark") ||
cls.contains("obsidian-dark");
const isLight =
cls.contains("theme-light") ||
cls.contains("mod-light") ||
cls.contains("is-light") ||
cls.contains("light") ||
cls.contains("obsidian-light");
if (isDark) bg = "#000";
else if (isLight) bg = "#fff";
}
} catch (e) {
// ignore cross-origin access
}
// 2) Fallback: system theme
if (!bg) {
bg = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "#000" : "#fff";
}
// Set CSS var on the SVG root. (Used by .bg fill.)
svg.style.setProperty("--page-bg", bg);
// Optional: if we're forcing a dark bg, switch black text/arrows to white for legibility.
// (The CSS @media dark already does this for system theme; this handles Obsidian manual toggle.)
const isForcedDark = bg === "#000";
svg.classList.toggle("force-dark", isForcedDark);
if (isForcedDark) {
svg.querySelectorAll(".tBlack").forEach(n => n.setAttribute("fill", "#fff"));
svg.querySelectorAll(".arrow").forEach(n => n.setAttribute("stroke", "#fff"));
const hint = svg.querySelector(".hint");
if (hint) hint.setAttribute("fill", "rgba(255,255,255,.65)");
} else {
svg.querySelectorAll(".tBlack").forEach(n => n.setAttribute("fill", "#000"));
svg.querySelectorAll(".arrow").forEach(n => n.setAttribute("stroke", "#000"));
const hint = svg.querySelector(".hint");
if (hint) hint.setAttribute("fill", "rgba(0,0,0,.55)");
}
}
applyResponsiveBackground();
// Watch for changes (Obsidian can toggle theme without reload)
try {
const pb = window.parent && window.parent.document && window.parent.document.body;
if (pb) {
const mo = new MutationObserver(() => applyResponsiveBackground());
mo.observe(pb, { attributes: true, attributeFilter: ["class"] });
}
} catch (e) {}
if (window.matchMedia) {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
if (mq.addEventListener) mq.addEventListener("change", applyResponsiveBackground);
else if (mq.addListener) mq.addListener(applyResponsiveBackground);
}
const prefersReduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const el = (id) => document.getElementById(id);
const b1 = el("b1");
const b6 = el("b6");
const mids = [el("b2"), el("b3"), el("b4"), el("b5")];
// BEFORE arrows
const ar1 = el("ar_b1");
const ar2 = el("ar_b2");
const ar3 = el("ar_b3");
const ar4 = el("ar_b4");
const ar5 = el("ar_b5");
// AFTER-only merged middle (box only — no new arrows)
const afterAdd = el("afterAdd");
const D = prefersReduced ? 1 : 950;
const ease = "cubic-bezier(.2,.8,.2,1)";
// Keep handles to every animation so we can reverse on the next click
let anims = [];
function setBaseBefore() {
// Clear inline styles so the "before" state is truly clean
[b1, b6, ...mids, ar1, ar2, ar3, ar4, ar5, afterAdd].forEach(n => {
if (!n) return;
n.style.transform = "";
n.style.opacity = "";
n.style.filter = "";
n.style.pointerEvents = "";
});
afterAdd.style.opacity = "0";
afterAdd.style.pointerEvents = "none";
}
function cancelAll() {
anims.forEach(a => { try { a.cancel(); } catch(e) {} });
anims = [];
}
function playForward() {
cancelAll();
setBaseBefore();
// Target positions (horizontal only)
const b1dx = 244; // 14 -> 258
const b6dx = -242; // 824 -> 582
// Boxes slide in
anims.push(
b1.animate(
[{ transform: "translateX(0px)" }, { transform: `translateX(${b1dx}px)` }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
b6.animate(
[{ transform: "translateX(0px)" }, { transform: `translateX(${b6dx}px)` }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
// Outer arrows move with the outer rectangles and remain visible
anims.push(
ar1.animate(
[{ transform: "translateX(0px)", opacity: 1 }, { transform: `translateX(${b1dx}px)`, opacity: 1 }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
ar5.animate(
[{ transform: "translateX(0px)", opacity: 1 }, { transform: `translateX(${b6dx}px)`, opacity: 1 }],
{ duration: D, easing: ease, fill: "forwards" }
)
);
// Middle boxes converge toward the merged box and fade out (subtle morph blur)
const starts = [176, 338, 500, 661];
mids.forEach((g, i) => {
const dx = 420 - starts[i];
anims.push(
g.animate(
[
{ transform: "translateX(0px) scale(1)", opacity: 1, filter: "blur(0px)" },
{ transform: `translateX(${dx}px) scale(0.92)`, opacity: 0, filter: "blur(1.6px)" }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
});
// Middle arrows (ONLY the three middle arrows) move+shrink and fade out
anims.push(
ar2.animate(
[
{ transform: "translateX(0px) scaleX(1)", opacity: 1 },
{ transform: `translateX(${420-176}px) scaleX(0.15)`, opacity: 0 }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
ar3.animate(
[
{ transform: "translateX(0px) scaleX(1)", opacity: 1 },
{ transform: `translateX(${420-338}px) scaleX(0.15)`, opacity: 0 }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
anims.push(
ar4.animate(
[
{ transform: "translateX(0px) scaleX(1)", opacity: 1 },
{ transform: `translateX(${420-500}px) scaleX(0.15)`, opacity: 0 }
],
{ duration: D, easing: ease, fill: "forwards" }
)
);
// Merged middle scales+deblurs in
afterAdd.style.pointerEvents = "none";
anims.push(
afterAdd.animate(
[
{ opacity: 0, transform: "scale(0.92)", filter: "blur(1.2px)" },
{ opacity: 1, transform: "scale(1)", filter: "blur(0px)" }
],
{ duration: D, delay: Math.max(0, D * 0.18), easing: ease, fill: "forwards" }
)
);
}
// Init (load in the BEFORE state; animation runs on click)
setBaseBefore();
// Toggle: reverse on every click (and forward again on the next click)
svg.addEventListener("click", () => {
if (!anims.length) {
playForward();
return;
}
anims.forEach(a => { try { a.reverse(); } catch(e) {} });
});
})();
]]></script>
</svg>
<svg viewBox="0 0 300 100" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:300px;height:auto;">
<style>
.box { fill: #78BE20; cursor: pointer; }
.label { font: 700 16px system-ui, sans-serif; fill: black; }
@media (prefers-color-scheme: dark) {
.label { fill: white; }
}
</style>
<rect class="box" x="20" y="20" width="260" height="60" rx="16"/>
<text class="label" x="150" y="55" text-anchor="middle" dominant-baseline="middle">Inline SVG test</text>
</svg>
<svg viewBox="0 0 300 100" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:300px;height:auto;">
<rect x="20" y="20" width="260" height="60" rx="16" fill="#78BE20"/>
<text x="150" y="55" text-anchor="middle" dominant-baseline="middle"
style="font:700 16px system-ui, sans-serif; fill:#000;">
Inline SVG test
</text>
</svg>
<svg viewBox="0 0 320 120" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:320px;height:auto;">
<rect id="r" x="20" y="30" width="120" height="60" rx="14" fill="#78BE20"/>
<text x="80" y="60" text-anchor="middle" dominant-baseline="middle"
style="font:700 14px system-ui, sans-serif; fill:#000;">Click box</text>
<animate xlink:href="#r"
attributeName="x"
from="20" to="180"
dur="0.6s"
begin="r.click"
fill="freeze" />
</svg>
<svg viewBox="0 0 300 100" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:300px;height:auto;">
<style>
.flipcard-test-rect { fill: #78BE20; }
.flipcard-test-label { font: 700 16px system-ui, sans-serif; fill: #000; }
@media (prefers-color-scheme: dark) {
.flipcard-test-label { fill: #fff; }
}
</style>
<rect class="flipcard-test-rect" x="20" y="20" width="260" height="60" rx="16"/>
<text class="flipcard-test-label" x="150" y="55" text-anchor="middle" dominant-baseline="middle">Inline SVG test</text>
</svg>
- Reduced design time from 0.5-5 hours to 2-5 minutes
- Enabled the entire design team to create flipcards (no Articulate license required!)
- Freed license holders add higher-value in more creative work
- Reduced dependence on a tool that costs thousands of dollars per license
**Beyond speed, the tool also solved recurring quality issues:**
- **Smoother animations.** Getting the flip animation right in Storyline is notoriously finicky. The generator handles it correctly every time.
- **Working hyperlinks.** In Storyline, clicking a link inside a flipcard would accidentally trigger the flip. That bug doesn't exist here.
- **Accessibility built in.** Alt-text editing is surfaced to the user; everything else is handled automatically behind the scenes.
Problem:
- manual work
This reduction in time hints at the low variance in the flipcard's design. Flipcards follow predictable layouts. There's only a certain number of ways you can arrange cards on a screen and have them look aesthetic. The content that goes inside the cards is finalized before you even start designing. After After you've learned the software the first few times, every subsequent flipcard is just a chore.
That made a routine, low-variance design task dependent on a small set of license holders, which introduced wait time and pulled specialized team members into work that did not require much judgment once the content had been finalized.
### Workflow Comparison
**Before:**
1. ID designs the course and identifies a need for a flipcard
2. Flipcard content is finalized
3. A request is submitted _(5–10 min)_
4. An Articulate license holder creates the flipcard _(1–5 hours)_
5. The ID QAs the design; someone else checks accessibility _(~30 min)_
6. Uploaded to AWS
**After:**
1. ID designs the course and identifies a need for a flipcard
2. Flipcard content is finalized
3. ID creates the flipcard themselves using the generator _(2–5 min)_
4. Uploaded to AWS
The request process is gone. The wait time — which could stretch depending on how busy license holders were — is gone. QA is effectively eliminated because the ID creates it themselves, and accessibility is baked into the output.
### Why It Mattered
This wasn't just a time-saving tool. It changed who could do the work, reduced dependency on expensive software, and gave the team back hours every week to spend on work that actually requires their expertise.