Skip to content
Star on GitHub
Sources and outputSource stack
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Source stack

Everything the agent read, folded into one quiet pill. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add source-stack

Annual plans convert about 18% better when the monthly price is shown crossed out next to them.

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add source-stack

React

$npx designme add source-stack --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Source stack sits under an agent's answer and folds every source it read into one small pill with a few favicons and a count. Open the pill to see the full list, filter it by kind, and see how much each source counted toward the answer.

  • When an agent reads many sources and listing them all would push the answer out of view.
  • When sources come from different places, like the web, files and Figma, and people want to narrow them down.
  • When you want to show how relevant each source was without a long explanation.
  • When people should be able to jump to any source from the list.
03

Usage

Import it and drive it from your own state.

Import

import { SourceStack } from '@/components/designme/source-stack/SourceStack';

Example

The demo above as a React component, 32 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: the sources open a moment after the answer appears.
import { useRef, useState } from 'react';
import { SourceStack, type StackSource } from './SourceStack';
import { useStory } from '../lib/useScript';

const open = () => {};
const SOURCES: StackSource[] = [
  { kind: 'web', title: 'Baymard: pricing page UX', host: 'baymard.com', relevance: .92, mark: 'B', color: 'var(--dm-pb)', onOpen: open },
  { kind: 'web', title: 'Annual vs monthly anchoring study', host: 'cxl.com', relevance: .88, mark: 'C', color: '#d9480f', onOpen: open },
  { kind: 'file', title: 'Q3 conversion export.csv', host: 'Drive', relevance: .84, mark: 'CSV', color: 'var(--dm-pm)', onOpen: open },
  { kind: 'figma', title: 'Pricing v4, frame 2', host: 'Figma', relevance: .8, mark: 'F', color: 'var(--dm-pl)', onOpen: open },
  { kind: 'web', title: 'Stripe billing docs', host: 'stripe.com', relevance: .66, mark: 'S', color: '#635bff', onOpen: open },
  { kind: 'file', title: 'Pricing research notes', host: 'Notion', relevance: .61, mark: 'N', color: 'var(--dm-fg2)', onOpen: open },
  { kind: 'figma', title: 'Checkout flow', host: 'Figma', relevance: .52, mark: 'F', color: 'var(--dm-pl)', onOpen: open },
  { kind: 'web', title: 'NN/g on price framing', host: 'nngroup.com', relevance: .48, mark: 'N', color: 'var(--dm-fg2)', onOpen: open }
];

export default function SourceStackDemo() {
  const [isOpen, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  // the sources open on their own a moment after it appears, unless you take over first
  useStory(ref, async ({ wait }) => { await wait(1100); setOpen(true); });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <SourceStack sources={SOURCES} open={isOpen} onOpenChange={setOpen}>
        Annual plans convert about 18% better when the monthly price is shown crossed out next to them.
      </SourceStack>
    </div>
  );
}
04

Props

Everything SourceStack takes.

PropTypeWhat it does
childrenReactNodeThe answer the sources back up, shown above the pill
sourcesrequiredStackSource[]
filtersSourceFilter[]The chips above the list. Defaults to All, Web, Files and Figma
filterstringThe chosen filter. Leave it out to let the chips switch it
defaultFilterstring
onFilterChange(filter: string) => void
openbooleanWhether the list is open. Leave it out to let the pill toggle it
defaultOpenboolean
onOpenChange(open: boolean) => void
classNamestring
05

Code

The full source, as the CLI installs it.

SourceStack.tsxReact · 92 lines
'use client';
import { useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { enter, Fold, Tip } from '../lib/motion';
import '../designme.css';
import './source-stack.css';

export interface StackSource {
  /** What kind of source it is. The filter chips pick by this, like 'web', 'file' or 'figma' */
  kind: string;
  title: string;
  /** Where it came from, like "baymard.com" or "Drive" */
  host: string;
  /** 0 to 1, drawn as a small bar */
  relevance: number;
  /** One to three letters in the favicon square, like "B" or "CSV" */
  mark: string;
  /** The favicon colour, any CSS colour, like '#635bff' or 'var(--dm-pl)' */
  color: string;
  /** Adds an open button that shows on hover */
  onOpen?: () => void;
}

export interface SourceFilter {
  /** 'all', or a source kind */
  value: string;
  label: string;
}

export interface SourceStackProps {
  /** The answer the sources back up, shown above the pill */
  children?: ReactNode;
  sources: StackSource[];
  /** The chips above the list. Defaults to All, Web, Files and Figma */
  filters?: SourceFilter[];
  /** The chosen filter. Leave it out to let the chips switch it */
  filter?: string;
  defaultFilter?: string;
  onFilterChange?: (filter: string) => void;
  /** Whether the list is open. Leave it out to let the pill toggle it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  className?: string;
}

const FILTERS: SourceFilter[] = [{ value: 'all', label: 'All' }, { value: 'web', label: 'Web' }, { value: 'file', label: 'Files' }, { value: 'figma', label: 'Figma' }];

/** An answer with its sources stacked in a pill. Open it to see each source, filter them, and how much each one counted. */
export function SourceStack({ children, sources, filters = FILTERS, filter, defaultFilter = 'all', onFilterChange, open, defaultOpen = false, onOpenChange, className }: SourceStackProps) {
  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const [f, setF] = useControllable(filter, defaultFilter, onFilterChange);
  // the list staggers in again each time it opens
  const [opened, setOpened] = useState(0);
  const [wasOpen, setWasOpen] = useState(isOpen);
  if (isOpen !== wasOpen) { setWasOpen(isOpen); if (isOpen) setOpened(n => n + 1); }
  const items = sources.filter(s => f === 'all' || s.kind === f);

  return (
    <div data-dm="" className={cx('dm-ssx', className)}>
      {children !== undefined && <p className="dm-msg">{children}</p>}
      <button type="button" className="dm-ssx-pill" aria-expanded={isOpen} onClick={() => setOpen(!isOpen)}>
        <span className="dm-ssx-ics">{sources.slice(0, 4).map((s, i) => <span key={i} style={{ background: s.color }}>{s.mark[0]}</span>)}</span>
        <span className="dm-ssx-l">{sources.length} {sources.length === 1 ? 'source' : 'sources'}</span>
        <Icon name="chev-d" size="xs" />
      </button>
      <Fold open={isOpen} className="dm-ssx-box">
        <div className="dm-ssx-in">
          <div className="dm-ssx-fl">
            {filters.map(x => <button key={x.value} type="button" className={cx('dm-chip dm-sm', x.value === f && 'dm-on')} aria-pressed={x.value === f} onClick={() => setF(x.value)}>{x.label}</button>)}
          </div>
          <div className="dm-ssx-list" key={f + ':' + opened}>
            {items.map((s, i) => {
              const e = enter(i * 40), pct = Math.round(Math.max(0, Math.min(1, s.relevance)) * 100);
              return (
                <div key={s.title + i} className={cx('dm-ssx-s', e.className)} style={e.style}>
                  <span className="dm-fv" style={{ background: `color-mix(in srgb,${s.color} 14%,transparent)`, color: s.color }}>{s.mark}</span>
                  <span className="dm-t">{s.title}<span className="dm-h"> · {s.host}</span></span>
                  <Tip label={`Relevance ${pct}%`}><span className="dm-rel" role="meter" aria-label="Relevance" aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}><i style={{ width: pct + '%' }} /></span></Tip>
                  {s.onOpen && <Tip label="Open"><button type="button" className="dm-ib" aria-label="Open source" onClick={s.onOpen}><Icon name="ext" size="xs" /></button></Tip>}
                </div>
              );
            })}
          </div>
        </div>
      </Fold>
    </div>
  );
}
source-stack.cssReact · 30 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Source stack ---------- */
.dm-ssx{display:flex;flex-direction:column;gap:10px;align-items:flex-start}
.dm-ssx-pill{display:inline-flex;align-items:center;gap:var(--dm-s-2);height:30px;padding:0 10px 0 5px;border-radius:var(--dm-r-pill);background:var(--dm-chip);font-size:12.5px;color:var(--dm-fg2);transition:background .25s,color .25s}
@media (hover:hover) and (pointer:fine){.dm-ssx-pill:hover{background:var(--dm-hover);color:var(--dm-fg)}}
.dm-ssx-pill>svg{transition:transform .4s var(--dm-ease)}
.dm-ssx-pill[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-ssx-ics{display:flex}
.dm-ssx-ics span{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:9px;font-weight:500;color:#fff;box-shadow:0 0 0 2px var(--dm-chip);margin-right:-6px;transition:margin .4s var(--dm-ease)}
@media (hover:hover) and (pointer:fine){.dm-ssx-pill:hover .dm-ssx-ics span{margin-right:-3px}}
.dm-ssx-ics span:last-child{margin-right:0}
.dm-ssx-box{width:100%}
.dm-ssx-in{padding:2px 0 var(--dm-s-1);display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-ssx-fl{display:flex;gap:var(--dm-s-1);flex-wrap:wrap}
/* the filters are white chips with a dimmed grey edge; the picked one's edge is a step stronger */
.dm-ssx-fl .dm-chip{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.dm-ssx-fl .dm-chip:hover{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}}
.dm-ssx-list{display:flex;flex-direction:column;gap:2px;margin-inline:-6px}
.dm-ssx-s{display:flex;align-items:center;gap:10px;padding:7px 6px;border-radius:10px;font-size:13px;transition:background .25s}
@media (hover:hover) and (pointer:fine){.dm-ssx-s:hover{background:var(--dm-row)}}
.dm-ssx-s .dm-fv{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;font-size:9.5px;font-weight:500;flex:none}
.dm-ssx-s .dm-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-ssx-s .dm-h{font-size:12px;color:var(--dm-muted)}
.dm-ssx-s .dm-rel{width:36px;height:4px;border-radius:2px;background:var(--dm-line);overflow:hidden;flex:none}
.dm-ssx-s .dm-rel i{display:block;height:100%;background:var(--dm-accent-mark,var(--dm-accent));border-radius:2px;transform-origin:left;animation:dm-ssrel .8s var(--dm-ease) both}
@keyframes dm-ssrel{from{transform:scaleX(0)}}
.dm-ssx-s .dm-ib{width:24px;height:24px;opacity:0;transition:opacity .2s}
@media (hover:hover) and (pointer:fine){.dm-ssx-s:hover .dm-ib{opacity:1}}
.dm-ssx-s .dm-ib:focus-visible{opacity:1}
source-stack.htmlHTML · 13 lines
<div class="card bare" data-c="source-stack">
  <div class="stage-in">
    <div class="ssx">
      <p class="msg">Annual plans convert about 18% better when the monthly price is shown crossed out next to them.</p>
      <button class="ssx-pill" aria-expanded="false"><span class="ssx-ics"></span><span class="ssx-l">8 sources</span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
      <div class="fold shut ssx-box"><div><div class="ssx-in">
        <div class="ssx-fl"><button class="chip sm on" data-f="all">All</button><button class="chip sm" data-f="web">Web</button><button class="chip sm" data-f="file">Files</button><button class="chip sm" data-f="figma">Figma</button></div>
        <div class="ssx-list"></div>
      </div></div></div>
    </div>
  </div>
</div>
source-stack.cssHTML · 29 lines
/* ---------- Source stack ---------- */
.ssx{display:flex;flex-direction:column;gap:10px;align-items:flex-start}
.ssx-pill{display:inline-flex;align-items:center;gap:var(--s-2);height:30px;padding:0 10px 0 5px;border-radius:var(--r-pill);background:var(--chip);font-size:12.5px;color:var(--fg2);transition:background .25s,color .25s}
@media (hover:hover) and (pointer:fine){.ssx-pill:hover{background:var(--hover);color:var(--fg)}}
.ssx-pill>svg{transition:transform .4s var(--ease)}
.ssx-pill[aria-expanded="true"]>svg{transform:rotate(180deg)}
.ssx-ics{display:flex}
.ssx-ics span{width:20px;height:20px;border-radius:50%;display:grid;place-items:center;font-size:9px;font-weight:500;color:#fff;box-shadow:0 0 0 2px var(--chip);margin-right:-6px;transition:margin .4s var(--ease)}
@media (hover:hover) and (pointer:fine){.ssx-pill:hover .ssx-ics span{margin-right:-3px}}
.ssx-ics span:last-child{margin-right:0}
.ssx-box{width:100%}
.ssx-in{padding:2px 0 var(--s-1);display:flex;flex-direction:column;gap:var(--s-2)}
.ssx-fl{display:flex;gap:var(--s-1);flex-wrap:wrap}
/* the filters are white chips with a dimmed grey edge; the picked one's edge is a step stronger */
.ssx-fl .chip{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.ssx-fl .chip:hover{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}}
.ssx-list{display:flex;flex-direction:column;gap:2px;margin-inline:-6px}
.ssx-s{display:flex;align-items:center;gap:10px;padding:7px 6px;border-radius:10px;font-size:13px;transition:background .25s}
@media (hover:hover) and (pointer:fine){.ssx-s:hover{background:var(--row)}}
.ssx-s .fv{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;font-size:9.5px;font-weight:500;flex:none}
.ssx-s .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ssx-s .h{font-size:12px;color:var(--muted)}
.ssx-s .rel{width:36px;height:4px;border-radius:2px;background:var(--line);overflow:hidden;flex:none}
.ssx-s .rel i{display:block;height:100%;background:var(--accent-mark,var(--accent));border-radius:2px;transform-origin:left;animation:ssrel .8s var(--ease) both}
@keyframes ssrel{from{transform:scaleX(0)}}
.ssx-s .ib{width:24px;height:24px;opacity:0;transition:opacity .2s}
@media (hover:hover) and (pointer:fine){.ssx-s:hover .ib{opacity:1}}
.ssx-s .ib:focus-visible{opacity:1}
source-stack.jsHTML · 25 lines
DM.def('source-stack', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{chev}', I('chev-d','xs'));
  const S = [
    ['web','Baymard: pricing page UX','baymard.com',.92,'B','var(--pb)'],
    ['web','Annual vs monthly anchoring study','cxl.com',.88,'C','var(--pp)'],
    ['file','Q3 conversion export.csv','Drive',.84,'CSV','var(--pm)'],
    ['figma','Pricing v4, frame 2','Figma',.8,'F','var(--pl)'],
    ['web','Stripe billing docs','stripe.com',.66,'S','var(--pl)'],
    ['file','Pricing research notes','Notion',.61,'N','var(--fg2)'],
    ['figma','Checkout flow','Figma',.52,'F','var(--pl)'],
    ['web','NN/g on price framing','nngroup.com',.48,'N','var(--fg2)']
  ];
  const pill = $('.ssx-pill', r), box = $('.ssx-box', r), list = $('.ssx-list', r);
  $('.ssx-ics', r).innerHTML = S.slice(0, 4).map(s => `<span style="background:${s[5]}">${s[4][0]}</span>`).join('');
  function paint(f){
    const items = S.filter(s => f === 'all' || s[0] === f);
    list.innerHTML = items.map(s => `<div class="ssx-s"><span class="fv" style="background:color-mix(in srgb,${s[5]} 14%,transparent);color:${s[5]}">${s[4]}</span><span class="t">${s[1]}<span class="h"> · ${s[2]}</span></span><span class="rel" data-tip="Relevance ${Math.round(s[3] * 100)}%"><i style="width:${s[3] * 100}%"></i></span><button class="ib" aria-label="Open source" data-tip="Open">${I('ext','xs')}</button></div>`).join('');
    stagger($$('.ssx-s', list), 40);
  }
  pill.onclick = () => { const open = box.classList.toggle('shut') === false; pill.setAttribute('aria-expanded', open); if (open) paint($('.ssx-fl .on', r).dataset.f); };
  $$('.ssx-fl .chip', r).forEach(ch => ch.onclick = () => { $$('.ssx-fl .chip', r).forEach(x => x.classList.toggle('on', x === ch)); paint(ch.dataset.f); });
  // the preview opens the sources once on its own, unless you take over first
  c.auto(1100, () => { if (pill.getAttribute('aria-expanded') === 'false') pill.click(); });
});
06

Questions

What people ask about it.

How do I add my own source kinds?
Set kind on each source to any string and pass matching filters, like { value: 'slack', label: 'Slack' }. Keep a filter with value all if you want a show-everything chip.
Can I open the list as soon as the answer arrives?
Yes. Pass defaultOpen, or control it with open and onOpenChange. The demo opens it a moment after the answer appears.
What scale does relevance use?
A number from 0 to 1. It is clamped to that range and shown as a bar and a percentage.
07

More in Sources and output

What the agent read, what it produced and where each answer came from.

Code viewerCode the agent wrote, with line notes you can ask about.
Terminal streamShell commands the agent runs, with their output as it streams in.
Citation hover cardHover a citation to see the exact passage it came from.
Field extractionThe agent pulls fields out of a document and shows where each came from.
Metric insightA number, its trend, and the agent's read on why it moved.
Details panelProperties of a run you can change in place.
Task tableAgent tasks in a sortable table with bulk actions.