Sakuga Back to Sakuga
Theme
Sound
Jump to a component

AniCanonKit · Story

StoryScrubber

StoryScrubber moves through the story in story order (when things happen in the world, not when they were released).

@anicanon/sakuga/anicanonkitv1.0Used onWebFrom AniCanon™

Live, from @anicanon/sakuga 2.0.0

Sakuga for iOS and Android is coming

iOS

Web only

StoryScrubber is made for the web, and isn’t part of Sakuga for iOS yet.

Android

Web only

StoryScrubber is made for the web, and isn’t part of Sakuga for Android yet.

Use it for
Moving through the story in story order, when things happen in the world: a column per event under arc brackets, and a handle that names the moment ("After Fire at Sé").
Not for

Guidelines

  • One column per event, grouped under arc brackets. Under each event, marks show what told it (episode, manga, short, Studio).
  • The handle sits after an event and names it ("After Fire at Sé"), or "Now" at the end. Two handles compare two moments; the track moves the first, and the second is said with it ("After Fire at Sé, compared with Now").
  • Events with no place yet wait in the Not placed box at the end.
  • The track is a slider from 0 to the number of events, at the first handle. A screen reader says valueText, or the first handle's label when it is left out.
  • onChange(at) makes the track move by keyboard (arrows a step, Page Up and Page Down a tenth of the story, Home and End to either end); onFilter(filter) makes the filter buttons work. The events and arcs drawn in the track are listed beside it for screen readers, in story order.
StoryScrubberExample.jsxReact
import { StoryScrubber } from '@anicanon/sakuga/anicanonkit';

export default function StoryScrubberExample() {
  return (
    <StoryScrubber
      events={[
        { type: 'event', inside: true, told: ['play'] },
        { type: 'twist', inside: true, told: ['manga'] },
        { type: 'revelation', told: ['play'] },
      ]}
      arcs={[
        { name: 'Arc 1', count: 2, on: true },
        { name: 'Arc 2', count: 1 },
      ]}
      handles={[{ at: 2, label: 'After Arthur taken' }]}
      fill={[0, 2]}
      unplaced={{ state: 'none' }}
      filter={false}
    />
  );
}

PropsStoryScrubberProps

eventsrequired
{ type: EventType; title?: string; told?: IconName[]; inside?: boolean; mark?: boolean; }[]
arcsrequired
{ name: string; count: number; on?: boolean; }[]
handlesrequired
{ at: number; label: string; }[]
fillrequired
[number, number]
unplaced
{ state?: 'event' | 'none'; on?: boolean; icon?: IconName; }
filter
string | false
onChange
(at: number) => void
Arrow keys, Page and Home/End on the track: the new place, 0 to the number of events.
onFilter
(filter: string) => void
A filter button pressed: all, ep, manga or shorts.
valueText
string
What a screen reader says for the moment shown; the first handle's label when left out.
className
string

An IconName is any of the names Icon lists.

Types

type EventType = 'event' | 'twist' | 'revelation' | 'clue' | 'goal';
StoryScrubberExample.swiftiOS · coming soon
StoryScrubberExample.ktAndroid · coming soon

Each token reads a role, and the role lands on a value in each theme. Change the role and every component that reads it follows.

Colour

scrub-trackStoryScrubber: the story-order line.
bg-fillshade-a12rgba(11,10,16,0.12)
bg-fillwhite-a14rgba(255,255,255,0.14)
scrub-fillStoryScrubber: the stretch included at the handle.
accentcamellia-600#c2335f
accentcamellia-400#f4779f
scrub-handleStoryScrubber: handle and its pill.
accentcamellia-600#c2335f
accentcamellia-400#f4779f
scrub-handle-fgStoryScrubber: handle pill label.
on-accentneutral-0#ffffff
on-accentneutral-925#0b0a10
scrub-node-bgStoryScrubber: event node fill.
bg-canvasneutral-50#f6f6f8
bg-canvasneutral-950#07070a
scrub-node-ringStoryScrubber: event node ring.
border-dividershade-a12rgba(11,10,16,0.12)
border-dividerwhite-a12rgba(255,255,255,0.12)
scrub-node-ring-onStoryScrubber: ring of an included event.
border-controlshade-a52rgba(11,10,16,0.52)
border-controlwhite-a45rgba(255,255,255,0.45)
scrub-arcStoryScrubber: arc brackets.
border-dividershade-a12rgba(11,10,16,0.12)
border-dividerwhite-a12rgba(255,255,255,0.12)
scrub-labelStoryScrubber: arc names, told-by marks and key.
text-mutedneutral-600#5c5b67
text-mutedneutral-400#a3a3a7
scrub-label-onStoryScrubber: an arc inside the moment.
text-secondaryneutral-800#2e2d36
text-secondaryneutral-200#dcdcde
scrub-unplaced-borderStoryScrubber: the Not placed box.
border-placeholdershade-a24rgba(11,10,16,0.24)
border-placeholderwhite-a22rgba(255,255,255,0.22)

Space and shape

scrub-gap
gap-lg
12px
scrub-padding-top
inset-xl
24px
scrub-row-gap
gap-xl
16px
scrub-track-gap
gap-sm
6px
scrub-line-top
inset-sm
8px
scrub-fill-top
inset-sm
8px
scrub-fill-height
its own value
4px
scrub-col-gap
gap-sm
6px
scrub-col-out-opacity
opacity-disabled
0.35
scrub-node-size
its own value
20px
scrub-node-ring-width
line-strong
2px
scrub-col-node-in-ring-width
line-strong
2px
scrub-node-mark-ring-width
line-strong
2px
scrub-node-unplaced-border-width
line-hairline
1px
scrub-told-gap
gap-2xs
2px
scrub-handle-top
its own value
calc(-1 * 2px)
scrub-handle-border-left-width
line-strong
2px
scrub-handle-translate-x
its own value
calc(2px / -2)
scrub-pill-top
its own value
calc(-1 * 24px)
scrub-pill-height
its own value
20px
scrub-pill-padding-x
inset-sm
8px
scrub-pill-radius
radius-full
9999px
scrub-arc-padding-x
inset-xs
4px
scrub-arc-gap
gap-xs
4px
scrub-bracket-height
its own value
4px
scrub-bracket-border-width
line-hairline
1px
scrub-bracket-radius
radius-xs
4px
scrub-arcname-padding-x
inset-xs
4px
scrub-unplaced-width
its own value
92px
scrub-unplaced-gap
gap-xs
4px
scrub-unplaced-out-opacity
opacity-disabled
0.35
scrub-unplaced-box-out-inset-ring-width
line-strong
2px
scrub-box-gap
gap-sm
6px
scrub-box-padding-top
inset-sm
8px
scrub-box-padding-bottom
inset-xs
4px
scrub-box-radius
radius-sm
8px
scrub-box-inset-ring-width
line-strong
2px
scrub-none-height
its own value
40px
scrub-foot-gap
gap-xl
16px
scrub-key-gap
gap-lg
12px
scrub-key-span-gap
gap-xs
4px
scrub-told-icon-size
icon-size-xs
14px
scrub-key-icon-size
icon-size-xs
14px
scrub-forced-edge-width
line-hairline
1px
scrub-track-focus-outline-offset
focus-ring-offset
3px
scrub-track-focus-radius
radius-xs
4px

Type

scrub-line-height
line-strong
2px
scrub-pill-font
text-micro
700 12px/16px "Funnel Sans", system-ui, -apple-system, "Segoe UI", sans-serif
scrub-arcname-font
text-small-strong
600 12px/16px "Funnel Sans", system-ui, -apple-system, "Segoe UI", sans-serif
scrub-none-font
text-small-strong
600 12px/16px "Funnel Sans", system-ui, -apple-system, "Segoe UI", sans-serif
scrub-key-font
text-small-strong
600 12px/16px "Funnel Sans", system-ui, -apple-system, "Segoe UI", sans-serif

Motion

scrub-col-opacity-duration
motion-base
240ms
scrub-col-easing
ease-standard
cubic-bezier(0.2, 0, 0, 1)
scrub-col-box-shadow-duration
motion-fast
160ms
scrub-node-duration
motion-fast
160ms
scrub-node-easing
ease-standard
cubic-bezier(0.2, 0, 0, 1)

Checked automatically with axe-core 4.11.4 against WCAG 2.2 A and AA, on the live preview above, in light and dark, on 26 October 2026.

Result
No issues found, in light or in dark.
By design
A slider that Tab reaches, named "Show the world at a moment in the story" and read as valueText (else the first handle's label, else "After event 3 of 12"); with onChange arrows move it an event, Page Up and Page Down a tenth of the story and Home and End to either end, and without it it reads as read-only. Beside it, screen readers get the events in story order (type, title, what told it, "in the moment shown") and the arcs as lists, and below it a group named "Show events told by" holds filter buttons that read "pressed" on the current one and run onFilter.
Not tested
The automated check doesn't test keyboard use, focus order or what a screen reader says. The lines above are the design system's own account of them.