Design Lounge
Install

Nº 433 of 520

Swinging kraft hang tag

A kraft-paper price tag on a green string that swings when you brush past it, with a struck price, a sale sticker, size chips, a hold button and a real EAN barcode.

Preview

Open full size
1280 × 800 · 15.4 KB · 267 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Swinging kraft hang tag" (piece card-retail-price-tag) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

256 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

A product card dressed as the physical swing tag on a garment, for a store’s markdown page or an in-store kiosk. A brass hook sits at the top of the frame; a loden-green string drops to the grommet of a kraft tag with clipped top corners. The tag carries the brand (Pike & Ollerton, est. 1931), the product (Brushed cotton overshirt), the old price struck with a red diagonal, the sale price in a 68px Didone, a round “−35% OFF” sticker, five size chips (XL sold out), a “Hold size M · 24 h” button, and a perforated stub with a real EAN-13 barcode. Brush the pointer across the tag and it swings like a pendulum in the direction you pushed, then settles. The detail worth copying is that the swing is a damped spring driven by the pointer’s horizontal velocity at entry, not a canned keyframe, so it feels pushed rather than animated.

Not receipt-slip (a till receipt) and not card-product-quick-add (a grid product card). This is one item’s tag.

Reference behaviour

  1. First frame: tag hangs at the rest angle of −3° (bottom slightly right). 500ms after load it gets one small push and swings, then settles within about 3s. Left column: “Store 14 · Rack C · Menswear”, “Autumn markdown” (76px italic Didone), a two-line note, an “Ends Sunday, 11 October” pill with a red dot, and a hint “Brush past the tag to swing it.” (hidden on touch-only devices).
  2. Brush: when the pointer enters the swing element, take the pointer’s horizontal speed from the last move (px/ms). Impulse = (0.35 + min(1, |vx| / 1.2) × 0.9) × 0.045 deg/ms, signed so the tag’s bottom moves the same way as the pointer. If there was no movement, push away from the side of entry.
  3. Pendulum: angular acceleration = −(a − rest) × 0.00005 − v × 0.0026 per ms². That gives a period of about 890ms and settles from an 8° swing in about 3s. Velocity is clamped to ±0.07 deg/ms (about ±10°). The loop stops when |a − rest| < .02° and |v| < .0005.
  4. Hovering does not keep it swinging; only entering does. The chips stay clickable because the swing decays quickly.
  5. Sizes: XS (2), S (6), M (3, selected), L (1), XL (0, sold out). Clicking a chip selects it, nudges the tag (±2.2°), and updates the stock line: “Only 3 left in M”, “6 in S on this rack”, “Last one in L on this rack” (bold with a red dot). Sold-out XL is dashed, struck, and cannot be selected.
  6. Arrow keys in the size group move the selection and skip sold-out sizes, wrapping at the ends.
  7. Hold: the button reads “Hold size M · 24 h” with a bookmark icon. Pressing it fills it ink, swaps to a check, and reads “Held till 5 Oct, 6 pm”; the stock line says “Size M is held at the fitting rooms until tomorrow, 6 pm”. Pressing again releases. Changing size while held releases the hold quietly and relabels the button.
  8. Keyboard: the tag itself is focusable. Focus gives a small nudge (±2°). Space or Enter on the tag gives a bigger one (±5°). Focus shows an inset ink ring inside the clipped shape.
  9. Reduced motion: the tag stays at −3°, never swings; everything else works.

Structure

1280 × 800, wall #e3ded2 with 47px pinstripes at 3.5% and a soft light upper right
┌──────────────────────────────────────────────────────────────────────────────┐
│                                                  ║ brass rail 14 × 58       │
│ STORE 14 · RACK C · MENSWEAR                      ╲╱ green string 96px tall  │
│ Autumn                                       ╱──────────╲                    │
│ markdown   (76px italic)                    │    ◎       │ grommet 26px      │
│ Green-string tags are already…              │  est. 1931 │                   │
│ [● Ends Sunday, 11 October]                 │PIKE & OLLERTON               │
│ Brush past the tag to swing it.             │────────────│                   │
│                                             │Brushed cotton overshirt        │
│                                             │Style 4471-LD · Loden · …       │
│  col 1fr                 gap 56px           │$1̶4̶8̶.̶0̶0̶          (−35%)│      │
│                                             │$96.00  (68px red)  sticker 68  │
│                                             │SIZE [XS][S][■M][L][X̸L̸]         │
│                                             │Only 3 left in M                │
│                                             │[ ⌑ HOLD SIZE M · 24 H ]        │
│                                             │- - - - - - - - - - - - - - - - │ perforation
│                                             │|||||||||||||   Sale ref.       │
│                                             │5 012345 447186  MD-26-35       │
│                                             └────────────────┘ tag 330px wide│
└──────────────────────────────────────────────────────────────────────────────┘
  • main.wrap: grid minmax(0,1fr) minmax(0,420px), gap 56px, max 1040px, full height.
  • Left: section.note labelled by the h1.
  • Right: .rack (relative, full height) with .rail (decorative) and .swing: absolute, top 66px, centred, 330px wide, transform-origin: 50% 0, rotate: var(--a). Inside: the string SVG (two cubic strands from the hook to the grommet), then a shadow wrapper, then article.tag[tabindex=0] labelled by the product name and described by the price block.
  • Tag: clip-path: polygon(18% 0, 82% 0, 100% 7%, 100% 100%, 0 100%, 0 7%), padding 58px 28px 0. Contents in order: grommet span, brand p, hairline, h2 product, meta p, .prices (was, now, sticker), fieldset.sizes with legend and a div[role=radiogroup] of five button[role=radio], stock p[aria-live=polite], hold button[aria-pressed], .stub with the barcode SVG (role=img) and the sale reference.
  • The shadow lives on a wrapper div around the tag, because clip-path clips the tag’s own filter.

Tokens

:root {
  --wall: #e3ded2;      /* page */
  --kraft-3: #d8b88f;   /* tag top */
  --kraft: #c9a47a;     /* tag middle */
  --kraft-2: #c29a6c;   /* tag bottom */
  --ink: #2a2017;       /* text, barcode, selected chip, held button */
  --ink-2: #3f3022;     /* brand line, legend, stock, sku */
  --ink-3: #4a3a2a;     /* meta, struck price, eyebrow */
  --sale: #8f2010;      /* sale price, strike line, sticker, low-stock dot, ends dot */
  --string: #4f6b3a;    /* loden-green string */
  --brass: #a8843f;     /* rail and hook */
  --focus: #2a2017;

  --serif: "Bodoni Moda", Didot, Georgia, serif;   /* opsz 6–96 */
  --mono: "Courier Prime", ui-monospace, monospace;

  --tw: 330px;          /* tag width */
  --rest: -3deg;
  --k: 0.00005;         /* spring, per ms² */
  --damp: 0.0026;       /* per ms */
  --vmax: 0.07;         /* deg per ms */

  --ease: cubic-bezier(.2, .7, .2, 1);
  --expo: cubic-bezier(.16, 1, .3, 1);
  --t-micro: 150ms;
}

Kraft surface, top to bottom of the stack:

background:
  radial-gradient(circle at 20% 30%, rgba(255,255,255,.12), transparent 40%),
  repeating-linear-gradient(17deg, rgba(90,60,30,.05) 0 1px, transparent 1px 4px),
  repeating-linear-gradient(-71deg, rgba(255,240,210,.06) 0 1px, transparent 1px 5px),
  linear-gradient(170deg, var(--kraft-3), var(--kraft) 40%, var(--kraft-2));

Shadow wrapper: drop-shadow(0 22px 22px rgba(42,32,23,.2)) drop-shadow(0 2px 2px rgba(42,32,23,.15)). Grommet: 26px circle filled with the wall colour, ring 0 0 0 5px #d9c9a8, outer hairline 0 0 0 6px rgba(42,32,23,.3), inner shade inset 0 2px 3px rgba(42,32,23,.35).

Typography

RoleFamilySizeWeightLine-heightTrackingCase
EyebrowCourier Prime12px4001.50.18emUPPER
HeadlineBodoni Moda italic, opsz 9676px5000.95-0.02emTitle
Note bodyCourier Prime14px4001.60sentence, 36ch
Brand est.Bodoni Moda italic13px50010.02emlower
Brand nameBodoni Moda22px70010.14emUPPER
ProductBodoni Moda24px5001.1-0.005emSentence
MetaCourier Prime12px4001.50.04emas written
Was priceCourier Prime16px4001.50—
Sale priceBodoni Moda, opsz 9668px ($ 30px, .00 26px superscript)7000.9-0.02emlining
StickerCourier Prime20px (OFF 10px, 0.12em)7000.90UPPER
LegendCourier Prime11px40010.16emUPPER
Chip, holdCourier Prime13px70010 / 0.08emUPPER
Barcode digitsCourier Prime10px4001.50.2em—
Sale ref.Courier Prime15px 700 over 10px labels1.20.02emUPPER

Motion

ElementTriggerPropertyFrom → ToDurationEasingReduced motion
.swingpointer entersrotate via --arest → damped swing, up to ~10°~3s to settlespring (k .00005, c .0026)stays −3°
.swingload + 500msrotateone push of .03 deg/ms (~4°)~3sspringnone
.swingchip pickrotate±2.2° impulse~2sspringnone
.swinghold togglerotate±3° (hold) / ±1.5° (release)~2sspringnone
.swingtag focus / Spacerotate±2° / ±5°~2–3sspringnone
Chip, holdhover / selectbackground, colour—150ms--easeinstant

The string rotates with the tag because it is inside .swing; the rail does not move. Use the individual rotate property, not transform, so nothing else on the element fights it.

States

  • Rest: −3°, still.
  • Swinging: angle written every animation frame until it settles; the loop then stops entirely.
  • Chip resting: 40px tall, 1.5px border at 45% ink, 3px radius. Hover: 8% ink fill. Selected: ink fill, --kraft-3 text, aria-checked="true", tabindex=0.
  • Sold out: dashed border, 42% ink text, a 1.5px diagonal strike at −24°, aria-disabled="true", not selectable by click or arrows.
  • Low stock (1 left): stock line bold ink with an 8px red dot.
  • Hold: 44px tall, 1.5px ink border. Hover: 8% ink fill. Held: ink fill, kraft text, check icon, aria-pressed="true".
  • Tag focus-visible: box-shadow: inset 0 0 0 3px ink, inset 0 0 0 5px kraft-3 (an outline would be clipped).
  • Other focus-visible: 2px ink outline, 3px offset.

Accessibility

  • The tag is an article labelled by the product name and described by the price block, so it reads “Brushed cotton overshirt, Was $148.00 Now $96.00”.
  • The struck price has a visually hidden “Was” and the sale price a hidden “Now”; the decorative $ and sticker are aria-hidden and a hidden $ is spoken instead.
  • Sizes are a radiogroup inside a fieldset with a legend. Each radio is labelled with its stock: “M, 3 left”, “XL, sold out”. Roving tabindex; arrows move and skip sold-out sizes.
  • Stock and hold messages share a polite live region.
  • The barcode SVG is role="img" with the number as its label; the digits are also printed.
  • The swing is decoration. No information depends on it, and it never runs under reduced motion.
  • Contrast on kraft #c9a47a: ink 6.9:1, --ink-2 5.5:1 (4.9:1 on the darker bottom #c29a6c), --ink-3 4.7:1 (used only in the lighter upper half). Sale red is used for the 68px price (3.8:1, large text) and as fills, never for small text. Sticker text #fbeee3 on #8f2010 7.7:1.
  • Hit targets: chips 40px tall and at least 46px wide; hold 44px.

Responsive rules

  • ≥ 1280: two columns, tag 330px, swing anchor 66px from the top. Tag bottom sits near y 760 at rest.
  • 901–1279: same layout; the note column narrows first.
  • ≤ 900: one column. Note centred on top (headline 48px), then the rack with min-height: 820px; the page scrolls vertically.
  • ≤ 420: tag 300px, padding 54px 22px 0, sale price 58px, headline 40px, note body hidden. .rack gets overflow-x: clip so the rotated tag never creates sideways scroll. Do not clip at wider sizes or the swing gets cut.
  • Touch: the hint hides under (hover: none). A tap still triggers pointerenter and gives a push.

Acceptance checklist

Always

  • The swing is a damped spring integrated per frame, pushed by pointer velocity on entry, settling and stopping on its own.
  • Rotation origin is the top of the string, and the string rotates with the tag.
  • The tag shape is a clip-path with clipped top corners and a grommet hole showing the wall colour.
  • The shadow is on a wrapper, not on the clipped element.
  • Old price is struck by a diagonal line, not just text-decoration, and the sale price dominates.
  • Size chips are a radiogroup with roving tabindex; sold-out sizes are visible, struck, and skipped.
  • The barcode is real bars generated from the digits, with the digits printed below.
  • Tag focus ring is visible inside the clip shape.
  • Reduced motion: no swing at all, everything else intact.
  • No horizontal scroll at 375px.

This demo

  • Pike & Ollerton, est. 1931; Brushed cotton overshirt, Style 4471-LD, Loden, 100% cotton twill.
  • Was $148.00, now $96.00, sticker −35% OFF.
  • Sizes XS 2, S 6, M 3 (selected), L 1, XL sold out.
  • Barcode 5 012345 447186; sale ref. MD-26-35, Rack C · 14.
  • Wall #e3ded2, kraft #c9a47a, sale #8f2010, string #4f6b3a.
  • Bodoni Moda for brand, product and prices; Courier Prime for everything typed.

Implementation notes

The pendulum. A semi-implicit Euler step per frame, clamped dt, and a stop condition so it doesn’t run forever:

let a = -3, v = 0, raf = 0;
const REST = -3, K = 0.00005, DAMP = 0.0026;
function run() {
  let last = performance.now();
  const tick = t => {
    const dt = Math.min(32, t - last); last = t;
    v += (-(a - REST) * K - v * DAMP) * dt;
    a += v * dt;
    swing.style.setProperty('--a', a.toFixed(3) + 'deg');
    if (Math.abs(a - REST) < .02 && Math.abs(v) < .0005) { a = REST; raf = 0; return; }
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
}
function kick(dv) { if (reduce) return; v = Math.max(-.07, Math.min(.07, v + dv)); if (!raf) run(); }

Sign: CSS rotate is clockwise-positive, and with the origin at the top a positive angle moves the bottom to the left. A pointer moving right must give a negative impulse: kick(-Math.sign(vx) * magnitude).

EAN-13 bars from the digits. The first digit chooses the L/G parity of the left six; the right six use R codes:

const L = ['0001101','0011001','0010011','0111101','0100011','0110001','0101111','0111011','0110111','0001011'];
const G = ['0100111','0110011','0011011','0100001','0011101','0111001','0000101','0010001','0001001','0010111'];
const R = L.map(p => [...p].map(c => c === '0' ? '1' : '0').join(''));
const PAR = ['LLLLLL','LLGLGG','LLGGLG','LLGGGL','LGLLGG','LGGLLG','LGGGLL','LGLGLG','LGLGGL','LGGLGL'];
const d = '5012345447186'.split('').map(Number);
let bits = '101';
d.slice(1, 7).forEach((n, k) => bits += (PAR[d[0]][k] === 'L' ? L : G)[n]);
bits += '01010';
d.slice(7).forEach(n => bits += R[n]);
bits += '101';                       // 95 modules: one 1-unit rect per '1', guards 48 tall, others 42

Perforated stub. A 2px dashed top border plus two wall-coloured 14px circles half off each edge:

.stub { position: relative; margin: 22px -28px 0; padding: 16px 28px 18px; border-top: 2px dashed rgba(42,32,23,.4); }
.stub::before, .stub::after { content: ""; position: absolute; top: -8px; width: 14px; height: 14px; border-radius: 50%; background: var(--wall); }
.stub::before { left: -7px; } .stub::after { right: -7px; }

Common mistakes:

  • A CSS @keyframes wobble on hover. It loops while you try to click a chip, and it ignores direction.
  • Putting filter: drop-shadow on the clipped tag; the shadow is clipped away.
  • A white string on a pale wall. It disappears; the string is the colour cue (“green-string tags”).
  • Small text in sale red on kraft (2.8–3.8:1). Red is for the big price and for fills.
  • outline for the tag’s focus state; clip-path cuts it off.
  • overflow: hidden on the rack at desktop widths, which slices the tag mid-swing.
  • A fake barcode of random stripes. Generate it from the printed number.

Rebuild order:

  1. Two-column layout, wall texture, rail and hook.
  2. Tag shape, kraft surface, grommet, shadow wrapper, and the string.
  3. Content: brand, product, prices with strike and sticker.
  4. Size radiogroup with stock line; hold toggle.
  5. Barcode and stub.
  6. Pendulum, pointer impulse, nudges, focus ring.
  7. Reduced motion and the narrow layouts.

Details

Palette
Type
Bodoni Moda · Courier Prime
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Abel

A catalogue. One product at a time.

All sources

On the shelves