エクスクルーシブ

Content Age <mv-content-age>

コンテンツの鮮度がひと目でわかります。任意のコンテンツ(ドキュメントページのヘッダー、ヘルプセンターの記事、料金表、ポリシー)を囲むとコンテンツはそのまま残り、隣の小さな額縁入りの肖像画が最終レビューからの経過時間に応じて年を取ります。

文化的リファレンス

ドリアン・グレイの肖像、オスカー・ワイルド(1890年、小説)。ドリアンは若く完璧なままで、隠された肖像画が彼の代わりに年を取り、時の痕跡を刻んでいきます。ここではコンテンツそのものは真新しいまま、その横の小さな肖像が最後のレビューからの経過時間に応じて年を取り、レビューすると若さを取り戻します。

仕組み

コンテンツの鮮度がひと目でわかります。任意のコンテンツ(ドキュメントページのヘッダー、ヘルプセンターの記事、料金表、ポリシー)を囲むと、コンテンツ自体はそのまま変わらず、その横の小さな額縁入りの肖像画が、最後のレビューからの経過時間に応じて年を取っていきます。経過率(updated からの経過時間 / lifespan)は 5 つの段階(fresh、settled、aging、stale、decrepit。しきい値は設定可能)に対応し、インライン SVG で描かれるシード付きのプロシージャルな胸像を動かします。項目ごとに固有の顔があり、年を取るにつれて色が褪せ、ニスが黄ばみ、細かなしわが現れ、顔立ちと肩が垂れ、髪は白くなり、金の額縁はくすんで緑青とひびが入り、ビネットにほこりがたまります。すべて連続的に変化します。肖像画はボタンになっていて、段階、正確な日付、寿命に対する経過ゲージ、担当者、そして「Mark as reviewed」アクションを表示するパネルを開きます。このアクションは滑らかなトゥイーンと輝きで肖像画を若返らせます。バッジのバリアントは「Aging · Reviewed 7 months ago」を追加します。配置は inline-start、inline-end、corner、または data-content-age-anchor を付けた任意の要素です。

カテゴリーデータ表示
タイプWeb Component(<mv-content-age>)
ステータス安定版
キット根拠を自ら語る数値
Keywordsexclusive, culture, freshness, staleness, content-review, last-updated, review-cycle, knowledge-base, documentation, policy, governance, age, portrait, generative, svg, procedural, popover, relative-time

When to use

  • Help-center or internal docs articles must show at a glance which ones are overdue for a review
  • A policy, pricing sheet or runbook page should make its own staleness obvious to readers and to its owner
  • Content owners need a lightweight in-place “Mark as reviewed” action next to the content they maintain
  • A list of documents should be scannable by freshness without adding a dedicated column of dates

Avoid when

  • Only a short status label is needed, with no age progression or review action → use Badge instead
  • The content changes continuously (live data, feeds) and has no review cycle to measure against
  • Hundreds of items render at once on a dense table, where a date column sorts and scans better → use Data table instead

インストール

node scripts/add.mjs content-age --out ./src/marvelous

Marvelous UI の MCP サーバーを使う AI エージェント:install_components({ slugs: ["content-age"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。

コピーされるファイル(依存関係を含む):tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/position.js, components/content-age/content-age.js, components/content-age/content-age.css。

使い方

クイックスタート、動作する最小限のマークアップ:

<mv-content-age updated="2026-03-02" lifespan="1y" owner="Legal">
  <h2>Data retention policy</h2>
  <p>How long we keep customer records, backups and logs.</p>
</mv-content-age>

基本となるマークアップです。ここから属性、data-*、CSS 変数でカスタマイズしてください:

<div id="ca-demo" style="width:min(100%,46rem);margin-inline:auto">
  <style>
    #ca-demo { display:grid; gap:1.75rem }
    #ca-demo .ca-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #ca-demo .ca-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #ca-demo .ca-head h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #ca-demo .ca-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #ca-demo .ca-list { list-style:none; margin:0; padding:.25rem 0 }
    #ca-demo .ca-list li + li { border-top:1px solid var(--mv-border) }
    #ca-demo .ca-list mv-content-age { align-items:center; padding:.75rem 1.25rem }
    #ca-demo .ca-list .mv-content-age-trigger { min-width:12.25rem }
    #ca-demo .ca-article { display:flex; align-items:center; gap:.875rem; min-width:0; color:inherit; text-decoration:none }
    #ca-demo .ca-icon { display:grid; place-items:center; flex:none; width:2.25rem; height:2.25rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-muted); color:var(--mv-fg-muted) }
    #ca-demo .ca-icon svg { width:1.05rem; height:1.05rem }
    #ca-demo .ca-article strong { display:block; font-size:.875rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
    #ca-demo .ca-article:hover strong { text-decoration:underline; text-underline-offset:.2em; text-decoration-color:var(--mv-border-strong) }
    #ca-demo .ca-meta { display:block; margin-top:.125rem; color:var(--mv-fg-muted); font-size:.75rem }
    #ca-demo .ca-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); color:var(--mv-fg-muted); font-size:.8125rem }
    #ca-demo .ca-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #ca-demo .ca-policy { padding:1.5rem }
    #ca-demo .ca-policy mv-content-age { --mv-content-age-size:88px; --mv-content-age-gap:1.5rem; align-items:center }
    #ca-demo .ca-kicker { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase }
    #ca-demo .ca-policy h2 { margin:.3rem 0 .4rem; font-size:1.5rem; line-height:1.2; letter-spacing:-.02em }
    #ca-demo .ca-policy p.ca-lede { margin:0; max-width:34rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.55 }
    #ca-demo .ca-tags { display:flex; gap:.375rem; flex-wrap:wrap; margin-top:.75rem }
    #ca-demo .ca-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(15rem,1fr)); gap:1rem }
    #ca-demo .ca-grid .ca-card { padding:1.125rem 1.25rem }
    #ca-demo .ca-grid mv-content-age { --mv-content-age-size:40px }
    #ca-demo .ca-grid h4 { margin:0 0 .25rem; padding-inline-end:3.25rem; font-size:.9375rem }
    #ca-demo .ca-grid p { margin:0; padding-inline-end:3.25rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
    #ca-demo .ca-grid .ca-price { margin-top:.75rem; padding:0; color:var(--mv-fg); font-size:1.25rem; font-weight:600; letter-spacing:-.02em }
    #ca-demo .ca-grid .ca-price small { color:var(--mv-fg-muted); font-size:.8125rem; font-weight:400 }
  </style>

  <!-- 1 · Policy page header, large portrait -->
  <section class="ca-card ca-policy" aria-label="Policy header">
    <mv-content-age id="ca-policy" data-days="152" lifespan="1y" owner="Amara Nwosu, Legal" seed="data-retention">
      <p class="ca-kicker">Policy · Version 3.2</p>
      <h2>Data Retention Policy</h2>
      <p class="ca-lede">How long we keep customer records, backups and logs, and how deletion requests are handled across every region we operate in.</p>
      <div class="ca-tags">
        <span class="mv-badge" data-variant="secondary">Legal</span>
        <span class="mv-badge" data-variant="outline">Applies to all workspaces</span>
      </div>
    </mv-content-age>
  </section>

  <!-- 2 · Knowledge base, badge variant -->
  <section class="ca-card" aria-labelledby="ca-kb-title">
    <div class="ca-head">
      <div>
        <h3 id="ca-kb-title">Help Center · Billing &amp; accounts</h3>
        <p>Each article ages against a 6-month review cycle.</p>
      </div>
      <span class="mv-badge" data-variant="outline">5 articles</span>
    </div>
    <ul class="ca-list" id="ca-kb">
      <li><mv-content-age variant="badge" placement="inline-end" data-days="3" owner="Priya Raman">
        <a class="ca-article" href="#"><span class="ca-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg></span>
          <span style="min-width:0"><strong>Update your payment method</strong><span class="ca-meta">Billing · 3 min read · 4,812 views</span></span></a>
      </mv-content-age></li>
      <li><mv-content-age variant="badge" placement="inline-end" data-days="61" owner="Marcus Chen">
        <a class="ca-article" href="#"><span class="ca-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h4"/></svg></span>
          <span style="min-width:0"><strong>Download invoices and receipts</strong><span class="ca-meta">Billing · 2 min read · 2,390 views</span></span></a>
      </mv-content-age></li>
      <li><mv-content-age variant="badge" placement="inline-end" data-days="152" owner="Sofia Alvarez">
        <a class="ca-article" href="#"><span class="ca-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
          <span style="min-width:0"><strong>Change your plan or seat count</strong><span class="ca-meta">Accounts · 4 min read · 1,975 views</span></span></a>
      </mv-content-age></li>
      <li><mv-content-age variant="badge" placement="inline-end" data-days="243" owner="Daniel Okafor">
        <a class="ca-article" href="#"><span class="ca-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
          <span style="min-width:0"><strong>Set up single sign-on with SAML 2.0</strong><span class="ca-meta">Security · 7 min read · 864 views</span></span></a>
      </mv-content-age></li>
      <li><mv-content-age variant="badge" placement="inline-end" data-days="730" owner="Hannah Weber">
        <a class="ca-article" href="#"><span class="ca-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/></svg></span>
          <span style="min-width:0"><strong>Migrate from the legacy v1 API</strong><span class="ca-meta">Developers · 9 min read · 512 views</span></span></a>
      </mv-content-age></li>
    </ul>
    <div class="ca-foot">
      <span id="ca-log" aria-live="polite">Open a portrait to see its dates, owner and review action.</span>
      <div class="ca-actions">
        <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="ca-forward">Fast-forward 3 months</button>
        <button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="ca-reset">Reset</button>
      </div>
    </div>
  </section>

  <!-- 3 · Cards, portrait in the corner -->
  <div class="ca-grid">
    <article class="ca-card">
      <mv-content-age placement="corner" data-days="18" lifespan="90d" owner="Revenue team">
        <h4>Enterprise pricing sheet</h4>
        <p>Per-seat pricing, volume tiers and the annual discount grid.</p>
        <p class="ca-price">$38 <small>per seat / month</small></p>
      </mv-content-age>
    </article>
    <article class="ca-card">
      <mv-content-age placement="corner" data-days="420" lifespan="1y" owner="Site Reliability">
        <h4>Incident response runbook</h4>
        <p>Severity levels, paging rotation and the customer comms template.</p>
        <p class="ca-price">SEV-1 <small>page within 5 minutes</small></p>
      </mv-content-age>
    </article>
  </div>

  <script type="module">
    const root = document.getElementById("ca-demo");
    const all = [...root.querySelectorAll("mv-content-age")];
    const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
    const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return iso(d); };
    let offset = 0;
    const reset = () => {
      offset = 0;
      for (const el of all) { el.removeAttribute("now"); el.setAttribute("updated", daysAgo(Number(el.dataset.days))); }
    };
    reset();

    const log = document.getElementById("ca-log");
    const title = (el) => el.querySelector("strong, h2, h4")?.textContent.trim() ?? "Item";
    root.addEventListener("mv-review", (e) => { log.textContent = `“${title(e.target)}” marked as reviewed.`; });
    root.addEventListener("mv-stage", (e) => { log.textContent = `“${title(e.target)}” is now ${e.detail.label.toLowerCase()}.`; });
    document.getElementById("ca-forward").addEventListener("click", () => {
      offset += 91;
      const now = new Date(); now.setDate(now.getDate() + offset);
      for (const el of all) el.setAttribute("now", now.toISOString());
      log.textContent = `Viewing the list as of ${now.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}.`;
    });
    document.getElementById("ca-reset").addEventListener("click", () => {
      reset();
      log.textContent = "Back to today.";
    });
  </script>
</div>

API

Attributes

NameタイプDefaultDescription
updateddate (YYYY-MM-DD | ISO 8601 | epoch ms)Date of the last review. A plain YYYY-MM-DD is read as a local calendar date. Missing or invalid: “Never reviewed”, fully aged (decrepit).
lifespanduration180dHow long the content stays trustworthy after a review: 36h, 90d, 26w, 6mo (or 6m), 1y; a bare number means days. The age ratio is (now − updated) / lifespan.
stagesstring0.25,0.5,0.8,1Four ascending ratio thresholds separating fresh | settled | aging | stale | decrepit. Ratio ≥ the last one (1 by default: past the lifespan) is decrepit.
stage-labelsstringFresh,Settled,Aging,Stale,DecrepitFive visible labels for the stages (badge, panel, accessible name), comma-separated.
seedstringSeed of the procedural face. Without it, the face is derived from a hash of the text content at first render, so each item gets its own stable face.
placementinline-start | inline-end | cornerinline-startWhere the portrait sits relative to the content: a column before or after it, or absolutely positioned in the top-end corner (leave room with padding on the content). Ignored when an element inside carries data-content-age-anchor: the portrait is then appended into it.
variantportrait | badgeportraitportrait: the framed portrait alone. badge: a compact bordered chip with a smaller portrait, the stage (dot + label) and “Reviewed 7 months ago”.
ownerstringPerson or team responsible for reviewing the content, shown in the panel.
labelstringFreshnessPrefix of the accessible name and panel kicker (“Freshness: aging, …”).
nowdateFreezes the clock at this date (tests, screenshots, server-rendered snapshots, “as of” views). Without it, the real time is used and re-checked every minute and on tab return.
readonlybooleanHides the “Mark as reviewed” action (readers who cannot review).
data-content-age-anchormarker attributeOn a descendant: the portrait button is appended into that element (e.g. a card’s meta line) instead of beside the content.
data-stagefresh | settled | aging | stale | decrepitSet by the component on the host (styleable).

Properties

NameタイプDescription
age{ ratio, stage, label, days, updated, due, lifespan, visual, now }Read-only snapshot: ratio (0…∞), stage key and label, days since the review, updated and due as Date (null if never reviewed), lifespan in days, visual age 0-1 used by the portrait (1 − e^(−1.1·ratio)).
updated / lifespan / stages / seed / placement / variant / owner / now / readonlyreflectedEvery attribute is mirrored as a property; changing one re-renders (age changes are tweened).

Methods

NameDescription
review()Marks the content as reviewed now (or at the frozen now): sets updated to an ISO timestamp, rejuvenates the portrait and emits mv-review.
open() / close()Opens (focus moves into it) or closes the details panel.
refresh()Recomputes age, stage and labels immediately (it also happens every minute, on tab return and on attribute changes).

Events

NameDescription
mv-review“Mark as reviewed” (or review()). detail: { previous: Date | null, updated: Date }. Persist it on your side; set updated back if the save fails.
mv-stageThe stage changed after the first render: time crossed a threshold while the page was open, updated, lifespan or now changed, or a review happened. detail: { stage, previous, ratio, days, label }.

Content structure

NameDescription
(content)Any content; it is moved into a .mv-content-age-body wrapper and never altered.

CSS classes

NameDescription
mv-content-age-triggerThe portrait button (aria-expanded, aria-controls) holding .mv-content-age-frame and, in the badge variant, .mv-content-age-text (.mv-content-age-stage + .mv-content-age-when).
mv-content-age-frame / mv-content-age-svgFrame box (4:5) and the procedural SVG inside it; data-size="lg" in the panel.
mv-content-age-bodyWrapper of the original content.
mv-content-age-panelDetails panel (Popover API, non-modal role=dialog): -panel-head, -kicker, -panel-stage, -panel-summary, -gauge (-meter, -meter-fill, -meter-tick, -meter-caption), -facts (dl), -review button.

CSS variables

NameタイプDefaultDescription
--mv-content-age-size44px (34px in badge)Portrait width; the height follows a 4:5 ratio.
--mv-content-age-panel-size72pxWidth of the large portrait in the panel.
--mv-content-age-gapvar(--mv-space-4)Space between portrait and content (inline placements).
--mv-content-age-inset0Offset from the corner (corner placement).
--mv-content-age-gildsoft gold (light-dark)Artistic: the frame’s gilding when fresh. Set it to var(--mv-border-strong) for a plain neutral frame.
--mv-content-age-patinamuted olive (light-dark)Artistic: what the gilding turns into with age (also the chips on the frame).
--mv-content-age-ageoutputSet on the host: visual age 0-1, usable to style the content itself (e.g. a faint border).

Accessibility

The stage is never conveyed by the picture alone: the portrait is a real <button> (aria-haspopup="dialog", aria-expanded, aria-controls) whose accessible name spells it out (“Freshness: aging, last reviewed 7 months ago, review due in 2 months” / “…, review overdue by 3 months”), the badge variant prints the stage label and relative time next to a colored dot, and the panel repeats stage, dates, lifespan share and owner in text. The procedural SVG is aria-hidden. The panel is a non-modal role="dialog" labelled by its stage heading, shown with the Popover API (top layer) and positioned next to the portrait; opening moves focus into it, Tab reaches “Mark as reviewed”, Escape or an outside click closes it and Escape returns focus to the portrait, and tabbing out closes it. After a review a polite role=status announces “Marked as reviewed. Freshness: fresh.”; stage crossings are only announced while the panel is open so a page full of items stays quiet. Visible focus rings on the portrait and the button; full touch support (plain taps). Relative times use Intl.RelativeTimeFormat en-US, dates Intl.DateTimeFormat. prefers-reduced-motion and [data-motion="reduce"]: no rejuvenation tween, glint or bounce, the portrait switches instantly to its new age; the panel appears without motion.

このページは AI で翻訳されています。翻訳の問題を報告